Merge branch 'main' into litellm_dev_10_08_2025_p2

This commit is contained in:
Krish Dholakia
2025-10-08 19:27:24 -07:00
committed by GitHub
80 changed files with 3550 additions and 306 deletions
+196 -1
View File
@@ -246,8 +246,203 @@ litellm_settings:
</TabItem>
</Tabs>
## Allow/Disallow MCP Tools
## Converting OpenAPI Specs to MCP Servers
LiteLLM can automatically convert OpenAPI specifications into MCP servers, allowing you to expose any REST API as MCP tools. This is useful when you have existing APIs with OpenAPI/Swagger documentation and want to make them available as MCP tools.
### Benefits
- **Rapid Integration**: Convert existing APIs to MCP tools without writing custom MCP server code
- **Automatic Tool Generation**: LiteLLM automatically generates MCP tools from your OpenAPI spec
- **Unified Interface**: Use the same MCP interface for both native MCP servers and OpenAPI-based APIs
- **Easy Testing**: Test and iterate on API integrations quickly
### Configuration
Add your OpenAPI-based MCP server to your `config.yaml`:
```yaml title="config.yaml - OpenAPI to MCP" showLineNumbers
model_list:
- model_name: gpt-4o
litellm_params:
model: openai/gpt-4o
api_key: sk-xxxxxxx
mcp_servers:
# OpenAPI Spec Example - Petstore API
petstore_mcp:
url: "https://petstore.swagger.io/v2"
spec_path: "/path/to/openapi.json"
auth_type: "none"
# OpenAPI Spec with API Key Authentication
my_api_mcp:
url: "http://0.0.0.0:8090"
spec_path: "/path/to/openapi.json"
auth_type: "api_key"
auth_value: "your-api-key-here"
# OpenAPI Spec with Bearer Token
secured_api_mcp:
url: "https://api.example.com"
spec_path: "/path/to/openapi.json"
auth_type: "bearer_token"
auth_value: "your-bearer-token"
```
### Configuration Parameters
| Parameter | Required | Description |
|-----------|----------|-------------|
| `url` | Yes | The base URL of your API endpoint |
| `spec_path` | Yes | Path or URL to your OpenAPI specification file (JSON or YAML) |
| `auth_type` | No | Authentication type: `none`, `api_key`, `bearer_token`, `basic`, `authorization` |
| `auth_value` | No | Authentication value (required if `auth_type` is set) |
| `description` | No | Optional description for the MCP server |
| `allowed_tools` | No | List of specific tools to allow (see [MCP Tool Filtering](#mcp-tool-filtering)) |
| `disallowed_tools` | No | List of specific tools to block (see [MCP Tool Filtering](#mcp-tool-filtering)) |
### Usage Example
Once configured, you can use the OpenAPI-based MCP server just like any other MCP server:
<Tabs>
<TabItem value="fastmcp" label="Python FastMCP">
```python title="Using OpenAPI-based MCP Server" showLineNumbers
from fastmcp import Client
import asyncio
# Standard MCP configuration
config = {
"mcpServers": {
"petstore": {
"url": "http://localhost:4000/petstore_mcp/mcp",
"headers": {
"x-litellm-api-key": "Bearer sk-1234"
}
}
}
}
# Create a client that connects to the server
client = Client(config)
async def main():
async with client:
# List available tools generated from OpenAPI spec
tools = await client.list_tools()
print(f"Available tools: {[tool.name for tool in tools]}")
# Example: Get a pet by ID (from Petstore API)
response = await client.call_tool(
name="getpetbyid",
arguments={"petId": "1"}
)
print(f"Response:\n{response}\n")
# Example: Find pets by status
response = await client.call_tool(
name="findpetsbystatus",
arguments={"status": "available"}
)
print(f"Response:\n{response}\n")
if __name__ == "__main__":
asyncio.run(main())
```
</TabItem>
<TabItem value="cursor" label="Cursor IDE">
```json title="Cursor MCP Configuration for OpenAPI Server" showLineNumbers
{
"mcpServers": {
"Petstore": {
"url": "http://localhost:4000/petstore_mcp/mcp",
"headers": {
"x-litellm-api-key": "Bearer $LITELLM_API_KEY"
}
}
}
}
```
</TabItem>
<TabItem value="openai" label="OpenAI Responses API">
```bash title="Using OpenAPI MCP Server with OpenAI" showLineNumbers
curl --location 'https://api.openai.com/v1/responses' \
--header 'Content-Type: application/json' \
--header "Authorization: Bearer $OPENAI_API_KEY" \
--data '{
"model": "gpt-4o",
"tools": [
{
"type": "mcp",
"server_label": "petstore",
"server_url": "http://localhost:4000/petstore_mcp/mcp",
"require_approval": "never",
"headers": {
"x-litellm-api-key": "Bearer YOUR_LITELLM_API_KEY"
}
}
],
"input": "Find all available pets in the petstore",
"tool_choice": "required"
}'
```
</TabItem>
</Tabs>
### How It Works
1. **Spec Loading**: LiteLLM loads your OpenAPI specification from the provided `spec_path`
2. **Tool Generation**: Each API endpoint in the spec becomes an MCP tool
3. **Parameter Mapping**: OpenAPI parameters are automatically mapped to MCP tool parameters
4. **Request Handling**: When a tool is called, LiteLLM converts the MCP request to the appropriate HTTP request
5. **Response Translation**: API responses are converted back to MCP format
### OpenAPI Spec Requirements
Your OpenAPI specification should follow standard OpenAPI/Swagger conventions:
- **Supported versions**: OpenAPI 3.0.x, OpenAPI 3.1.x, Swagger 2.0
- **Required fields**: `paths`, `info` sections should be properly defined
- **Operation IDs**: Each operation should have a unique `operationId` (this becomes the tool name)
- **Parameters**: Request parameters should be properly documented with types and descriptions
### Example OpenAPI Spec Structure
```yaml title="sample-openapi.yaml" showLineNumbers
openapi: 3.0.0
info:
title: My API
version: 1.0.0
paths:
/pets/{petId}:
get:
operationId: getPetById
summary: Get a pet by ID
parameters:
- name: petId
in: path
required: true
schema:
type: integer
responses:
'200':
description: Successful response
content:
application/json:
schema:
type: object
```
## Allow/Disallow MCP Tools
Control which tools are available from your MCP servers. You can either allow only specific tools or block dangerous ones.
<Tabs>
@@ -81,6 +81,23 @@ MICROSOFT_TENANT="5a39737
http://localhost:4000/sso/callback
```
**Using App Roles for User Permissions**
You can assign user roles directly from Entra ID using App Roles. LiteLLM will automatically read the app roles from the JWT token and assign the corresponding role to the user.
Supported roles:
- `proxy_admin` - Admin over the platform
- `proxy_admin_viewer` - Can login, view all keys, view all spend (read-only)
- `internal_user` - Normal user. Can login, view spend and depending on team-member permissions - view/create/delete their own keys.
To set up app roles:
1. Navigate to your App Registration on https://portal.azure.com/
2. Go to "App roles" and create a new app role
3. Use one of the supported role names above (e.g., `proxy_admin`)
4. Assign users to these roles in your Enterprise Application
5. When users sign in via SSO, LiteLLM will automatically assign them the corresponding role
</TabItem>
<TabItem value="Generic" label="Generic SSO Provider">
+2
View File
@@ -278,6 +278,8 @@ Set either `REDIS_URL` or the `REDIS_HOST` in your os environment, to enable cac
REDIS_HOST = "" # REDIS_HOST='redis-18841.c274.us-east-1-3.ec2.cloud.redislabs.com'
REDIS_PORT = "" # REDIS_PORT='18841'
REDIS_PASSWORD = "" # REDIS_PASSWORD='liteLlmIsAmazing'
REDIS_USERNAME = "" # REDIS_USERNAME='my-redis-username' [OPTIONAL] if your redis server requires a username
REDIS_SSL = "True" # REDIS_SSL='True' to enable SSL by default is False
```
**Additional kwargs**
@@ -140,6 +140,54 @@ litellm_settings:
<Image img={require('../../img/msft_default_settings.png')} style={{ width: '900px', height: 'auto' }} />
## 4. Using Entra ID App Roles for User Permissions
You can assign user roles directly from Entra ID using App Roles. LiteLLM will automatically read the app roles from the JWT token during SSO sign-in and assign the corresponding role to the user.
### 4.1 Supported Roles
LiteLLM supports the following app roles (case-insensitive):
- `proxy_admin` - Admin over the entire LiteLLM platform
- `proxy_admin_viewer` - Read-only admin access (can view all keys and spend)
- `org_admin` - Admin over a specific organization (can create teams and users within their org)
- `internal_user` - Standard user (can create/view/delete their own keys and view their own spend)
### 4.2 Create App Roles in Entra ID
1. Navigate to your App Registration on https://portal.azure.com/
2. Go to **App roles** > **Create app role**
3. Configure the app role:
- **Display name**: Proxy Admin (or your preferred display name)
- **Value**: `proxy_admin` (use one of the supported role values above)
- **Description**: Administrator access to LiteLLM proxy
- **Allowed member types**: Users/Groups
4. Click **Apply** to save the role
### 4.3 Assign Users to App Roles
1. Navigate to **Enterprise Applications** on https://portal.azure.com/
2. Select your LiteLLM application
3. Go to **Users and groups** > **Add user/group**
4. Select the user and assign them to one of the app roles you created
### 4.4 Test the Role Assignment
1. Sign in to LiteLLM UI via SSO as a user with an assigned app role
2. LiteLLM will automatically extract the app role from the JWT token
3. The user will be assigned the corresponding LiteLLM role in the database
4. The user's permissions will reflect their assigned role
**How it works:**
- When a user signs in via Microsoft SSO, LiteLLM extracts the `roles` claim from the JWT `id_token`
- If any of the roles match a valid LiteLLM role (case-insensitive), that role is assigned to the user
- If multiple roles are present, LiteLLM uses the first valid role it finds
- This role assignment persists in the LiteLLM database and determines the user's access level
## Video Walkthrough
This walks through setting up sso auto-add for **Microsoft Entra ID**
+3
View File
@@ -1190,6 +1190,9 @@ from .llms.azure.responses.transformation import AzureOpenAIResponsesAPIConfig
from .llms.azure.responses.o_series_transformation import (
AzureOpenAIOSeriesResponsesAPIConfig,
)
from .llms.litellm_proxy.responses.transformation import (
LiteLLMProxyResponsesAPIConfig,
)
from .llms.openai.chat.o_series_transformation import (
OpenAIOSeriesConfig as OpenAIO1Config, # maintain backwards compatibility
OpenAIOSeriesConfig,
+13 -6
View File
@@ -177,14 +177,21 @@ def get_redis_url_from_environment():
raise ValueError(
"Either 'REDIS_URL' or both 'REDIS_HOST' and 'REDIS_PORT' must be specified for Redis."
)
if "REDIS_PASSWORD" in os.environ:
redis_password = f":{os.environ['REDIS_PASSWORD']}@"
if "REDIS_SSL" in os.environ and os.environ["REDIS_SSL"].lower() == "true":
redis_protocol = "rediss"
else:
redis_password = ""
redis_protocol = "redis"
# Build authentication part of URL
auth_part = ""
if "REDIS_USERNAME" in os.environ and "REDIS_PASSWORD" in os.environ:
auth_part = f"{os.environ['REDIS_USERNAME']}:{os.environ['REDIS_PASSWORD']}@"
elif "REDIS_PASSWORD" in os.environ:
auth_part = f"{os.environ['REDIS_PASSWORD']}@"
return (
f"redis://{redis_password}{os.environ['REDIS_HOST']}:{os.environ['REDIS_PORT']}"
f"{redis_protocol}://{auth_part}{os.environ['REDIS_HOST']}:{os.environ['REDIS_PORT']}"
)
+5 -11
View File
@@ -14,10 +14,10 @@ It utilizes the (RedisCache, s3Cache, RedisSemanticCache, QdrantSemanticCache, I
In each method it will call the appropriate method from caching.py
"""
import time
import asyncio
import datetime
import inspect
import time
from typing import (
TYPE_CHECKING,
Any,
@@ -62,12 +62,10 @@ else:
LiteLLMLoggingObj = Any
from litellm.litellm_core_utils.streaming_handler import CustomStreamWrapper
from litellm.litellm_core_utils.core_helpers import (
_get_parent_otel_span_from_kwargs,
_get_parent_otel_span_from_kwargs,
)
from litellm.litellm_core_utils.streaming_handler import CustomStreamWrapper
class CachingHandlerResponse(BaseModel):
@@ -214,9 +212,7 @@ class LLMCachingHandler:
end_time=end_time,
cache_hit=cache_hit,
)
cache_key = litellm.cache._get_preset_cache_key_from_kwargs(
**kwargs
)
cache_key = litellm.cache.get_cache_key(**kwargs)
if (
isinstance(cached_result, BaseModel)
or isinstance(cached_result, CustomStreamWrapper)
@@ -330,9 +326,7 @@ class LLMCachingHandler:
end_time=end_time,
cache_hit=cache_hit
)
cache_key = litellm.cache._get_preset_cache_key_from_kwargs(
**kwargs
)
cache_key = litellm.cache.get_cache_key(**kwargs)
if (
isinstance(cached_result, BaseModel)
or isinstance(cached_result, CustomStreamWrapper)
+8 -1
View File
@@ -365,6 +365,11 @@ def get_azure_ad_token(
azure_ad_token_provider = get_azure_ad_token_provider(azure_scope=scope)
except ValueError:
verbose_logger.debug("Azure AD Token Provider could not be used.")
except Exception as e:
verbose_logger.error(
f"Error calling Azure AD token provider: {str(e)}. Follow docs - https://docs.litellm.ai/docs/providers/azure/#azure-ad-token-refresh---defaultazurecredential"
)
raise e
#########################################################
# If litellm.enable_azure_ad_token_refresh is True and no other token provider is available,
@@ -561,7 +566,9 @@ class BaseAzureLLM(BaseOpenAILLM):
"Using Azure AD token provider based on Service Principal with Secret workflow for Azure Auth"
)
try:
azure_ad_token_provider = get_azure_ad_token_provider(azure_scope=scope)
azure_ad_token_provider = get_azure_ad_token_provider(
azure_scope=scope,
)
except ValueError:
verbose_logger.debug("Azure AD Token Provider could not be used.")
if api_version is None:
@@ -0,0 +1,48 @@
"""
Responses API transformation for LiteLLM Proxy provider.
LiteLLM Proxy supports the OpenAI Responses API natively when the underlying model supports it.
This config enables pass-through behavior to the proxy's /v1/responses endpoint.
"""
from typing import Optional
from litellm.llms.openai.responses.transformation import OpenAIResponsesAPIConfig
from litellm.secret_managers.main import get_secret_str
from litellm.types.utils import LlmProviders
class LiteLLMProxyResponsesAPIConfig(OpenAIResponsesAPIConfig):
"""
Configuration for LiteLLM Proxy Responses API support.
Extends OpenAI's config since the proxy follows OpenAI's API spec,
but uses LITELLM_PROXY_API_BASE for the base URL.
"""
@property
def custom_llm_provider(self) -> LlmProviders:
return LlmProviders.LITELLM_PROXY
def get_complete_url(
self,
api_base: Optional[str],
litellm_params: dict,
) -> str:
"""
Get the endpoint for LiteLLM Proxy responses API.
Uses LITELLM_PROXY_API_BASE environment variable if api_base is not provided.
"""
api_base = api_base or get_secret_str("LITELLM_PROXY_API_BASE")
if api_base is None:
raise ValueError(
"api_base not set for LiteLLM Proxy responses API. "
"Set via api_base parameter or LITELLM_PROXY_API_BASE environment variable"
)
# Remove trailing slashes
api_base = api_base.rstrip("/")
return f"{api_base}/responses"
@@ -397,13 +397,13 @@ class OpenAIGPTConfig(BaseLLMModelInfo, BaseConfig):
)
from litellm.types.llms.openai import ChatCompletionToolParam
for message in messages:
message = cast(
for i, message in enumerate(messages):
messages[i] = cast(
AllMessageValues, filter_value_from_dict(message, "cache_control") # type: ignore
)
if tools is not None:
for tool in tools:
tool = cast(
for i, tool in enumerate(tools):
tools[i] = cast(
ChatCompletionToolParam,
filter_value_from_dict(tool, "cache_control"), # type: ignore
)
+65 -1
View File
@@ -6,6 +6,7 @@ Calls done in OpenAI/openai.py as OpenRouter is openai-compatible.
Docs: https://openrouter.ai/docs/parameters
"""
from enum import Enum
from typing import Any, AsyncIterator, Iterator, List, Optional, Tuple, Union
import httpx
@@ -20,6 +21,12 @@ from ...openai.chat.gpt_transformation import OpenAIGPTConfig
from ..common_utils import OpenRouterException
class CacheControlSupportedModels(str, Enum):
"""Models that support cache_control in content blocks."""
CLAUDE = "claude"
GEMINI = "gemini"
class OpenrouterConfig(OpenAIGPTConfig):
def map_openai_params(
self,
@@ -48,19 +55,73 @@ class OpenrouterConfig(OpenAIGPTConfig):
)
return mapped_openai_params
def _supports_cache_control_in_content(self, model: str) -> bool:
"""
Check if the model supports cache_control in content blocks.
Returns:
bool: True if model supports cache_control (Claude or Gemini models)
"""
model_lower = model.lower()
return any(
supported_model.value in model_lower
for supported_model in CacheControlSupportedModels
)
def remove_cache_control_flag_from_messages_and_tools(
self,
model: str,
messages: List[AllMessageValues],
tools: Optional[List["ChatCompletionToolParam"]] = None,
) -> Tuple[List[AllMessageValues], Optional[List["ChatCompletionToolParam"]]]:
if "claude" in model.lower(): # don't remove 'cache_control' flag
if self._supports_cache_control_in_content(model):
return messages, tools
else:
return super().remove_cache_control_flag_from_messages_and_tools(
model, messages, tools
)
def _move_cache_control_to_content(
self, messages: List[AllMessageValues]
) -> List[AllMessageValues]:
"""
Move cache_control from message level to content blocks.
OpenRouter requires cache_control to be inside content blocks, not at message level.
When cache_control is at message level, it's added to ALL content blocks
to cache the entire message content.
"""
transformed_messages = []
for message in messages:
message_copy = dict(message)
cache_control = message_copy.pop("cache_control", None)
if cache_control is not None:
content = message_copy.get("content")
if isinstance(content, list):
# Content is already a list, add cache_control to all blocks
if len(content) > 0:
content_copy = []
for block in content:
block_copy = dict(block)
block_copy["cache_control"] = cache_control
content_copy.append(block_copy)
message_copy["content"] = content_copy
else:
# Content is a string, convert to structured format
message_copy["content"] = [
{
"type": "text",
"text": content,
"cache_control": cache_control,
}
]
transformed_messages.append(message_copy)
return transformed_messages
def transform_request(
self,
model: str,
@@ -75,6 +136,9 @@ class OpenrouterConfig(OpenAIGPTConfig):
Returns:
dict: The transformed request. Sent as the body of the API call.
"""
if self._supports_cache_control_in_content(model):
messages = self._move_cache_control_to_content(messages)
extra_body = optional_params.pop("extra_body", {})
response = super().transform_request(
model, messages, optional_params, litellm_params, headers
@@ -12975,6 +12975,39 @@
"supports_vision": true,
"supports_web_search": true
},
"gpt-5-pro-2025-10-06": {
"input_cost_per_token": 1.5e-05,
"input_cost_per_token_batches": 7.5e-06,
"litellm_provider": "openai",
"max_input_tokens": 400000,
"max_output_tokens": 272000,
"max_tokens": 272000,
"mode": "responses",
"output_cost_per_token": 1.2e-04,
"output_cost_per_token_batches": 6e-05,
"supported_endpoints": [
"/v1/batch",
"/v1/responses"
],
"supported_modalities": [
"text",
"image"
],
"supported_output_modalities": [
"text"
],
"supports_function_calling": true,
"supports_native_streaming": false,
"supports_parallel_function_calling": true,
"supports_pdf_input": true,
"supports_prompt_caching": true,
"supports_reasoning": true,
"supports_response_schema": true,
"supports_system_messages": true,
"supports_tool_choice": true,
"supports_vision": true,
"supports_web_search": true
},
"gpt-5-codex": {
"cache_read_input_token_cost": 1.25e-07,
"input_cost_per_token": 1.25e-06,
@@ -195,6 +195,7 @@ class MCPServerManager:
name=name_for_prefix,
alias=alias,
server_name=server_name,
spec_path=server_config.get("spec_path", None),
url=server_config.get("url", None) or "",
command=server_config.get("command", None) or "",
args=server_config.get("args", None) or [],
@@ -219,12 +220,163 @@ class MCPServerManager:
access_groups=server_config.get("access_groups", None),
)
self.config_mcp_servers[server_id] = new_server
# Check if this is an OpenAPI-based server
spec_path = server_config.get("spec_path", None)
if spec_path:
verbose_logger.info(
f"Loading OpenAPI spec from {spec_path} for server {server_name}"
)
self._register_openapi_tools(
spec_path=spec_path,
server=new_server,
base_url=server_config.get("url", ""),
)
verbose_logger.debug(
f"Loaded MCP Servers: {json.dumps(self.config_mcp_servers, indent=4, default=str)}"
)
self.initialize_tool_name_to_mcp_server_name_mapping()
def _register_openapi_tools(self, spec_path: str, server: MCPServer, base_url: str):
"""
Register tools from an OpenAPI specification for a given server.
This creates "virtual" MCP tools from OpenAPI endpoints that are:
1. Registered in the global tool registry with server prefix
2. Mapped to the server for routing
3. Executed via the local tool handler
Args:
spec_path: Path to the OpenAPI specification file
server: The MCPServer instance to register tools for
base_url: Base URL for API calls
"""
from litellm.proxy._experimental.mcp_server.openapi_to_mcp_generator import (
build_input_schema,
create_tool_function,
)
from litellm.proxy._experimental.mcp_server.openapi_to_mcp_generator import (
get_base_url as get_openapi_base_url,
)
from litellm.proxy._experimental.mcp_server.openapi_to_mcp_generator import (
load_openapi_spec,
)
from litellm.proxy._experimental.mcp_server.tool_registry import (
global_mcp_tool_registry,
)
try:
# Load OpenAPI spec
spec = load_openapi_spec(spec_path)
# Use base_url from config if provided, otherwise extract from spec
if not base_url:
base_url = get_openapi_base_url(spec)
verbose_logger.info(
f"Registering OpenAPI tools for server {server.name} with base URL: {base_url}"
)
# Get server prefix for tool naming
server_prefix = get_server_prefix(server)
# Build headers from server configuration
headers = {}
# Add authentication headers if configured
if server.authentication_token:
from litellm.types.mcp import MCPAuth
if server.auth_type == MCPAuth.bearer_token:
headers["Authorization"] = f"Bearer {server.authentication_token}"
elif server.auth_type == MCPAuth.api_key:
headers["Authorization"] = f"ApiKey {server.authentication_token}"
elif server.auth_type == MCPAuth.basic:
headers["Authorization"] = f"Basic {server.authentication_token}"
# Add any extra headers from server config
# Note: extra_headers is a List[str] of header names to forward, not a dict
# For OpenAPI tools, we'll just use the authentication headers
# If extra_headers were needed, they would be processed separately
verbose_logger.debug(
f"Using headers for OpenAPI tools (excluding sensitive values): "
f"{list(headers.keys())}"
)
# Extract and register tools from OpenAPI paths
paths = spec.get("paths", {})
registered_count = 0
verbose_logger.debug(f"Processing {len(paths)} paths from OpenAPI spec")
for path, path_item in paths.items():
for method in ["get", "post", "put", "delete", "patch"]:
if method not in path_item:
continue
operation = path_item[method]
# Generate tool name (without prefix initially)
operation_id = operation.get(
"operationId", f"{method}_{path.replace('/', '_')}"
)
base_tool_name = operation_id.replace(" ", "_").lower()
# Add server prefix to tool name
prefixed_tool_name = add_server_prefix_to_tool_name(
base_tool_name, server_prefix
)
# Get description
description = operation.get(
"summary",
operation.get("description", f"{method.upper()} {path}"),
)
# Build input schema using imported function
input_schema = build_input_schema(operation)
# Create tool function with headers using imported function
tool_func = create_tool_function(
path, method, operation, base_url, headers=headers
)
tool_func.__name__ = prefixed_tool_name
tool_func.__doc__ = description
# Register tool with prefixed name in global registry
global_mcp_tool_registry.register_tool(
name=prefixed_tool_name,
description=description,
input_schema=input_schema,
handler=tool_func,
)
# Update tool name to server name mapping (for both prefixed and base names)
self.tool_name_to_mcp_server_name_mapping[base_tool_name] = (
server_prefix
)
self.tool_name_to_mcp_server_name_mapping[prefixed_tool_name] = (
server_prefix
)
registered_count += 1
verbose_logger.debug(
f"Registered OpenAPI tool: {prefixed_tool_name} for server {server.name}"
)
verbose_logger.info(
f"Successfully registered {registered_count} OpenAPI tools for server {server.name}"
)
except Exception as e:
verbose_logger.error(
f"Failed to register OpenAPI tools for server {server.name}: {str(e)}"
)
raise e
def remove_server(self, mcp_server: LiteLLM_MCPServerTable):
"""
Remove a server from the registry
@@ -470,6 +622,10 @@ class MCPServerManager:
Returns:
List[MCPTool]: List of tools available on the server with prefixed names
"""
from litellm.proxy._experimental.mcp_server.tool_registry import (
global_mcp_tool_registry,
)
verbose_logger.debug(f"Connecting to url: {server.url}")
verbose_logger.info(f"_get_tools_from_server for {server.name}...")
@@ -482,7 +638,14 @@ class MCPServerManager:
extra_headers=extra_headers,
)
tools = await self._fetch_tools_with_timeout(client, server.name)
## HANDLE OPENAPI TOOLS
if server.spec_path:
_tools = global_mcp_tool_registry.list_tools(tool_prefix=server.name)
tools = global_mcp_tool_registry.convert_tools_to_mcp_sdk_tool_type(
_tools
)
else:
tools = await self._fetch_tools_with_timeout(client, server.name)
prefixed_or_original_tools = self._create_prefixed_tools(
tools, server, add_prefix=add_prefix
@@ -598,9 +761,15 @@ class MCPServerManager:
Check if the tool is allowed or banned for the given server
"""
if server.allowed_tools:
return tool_name in server.allowed_tools
return (
tool_name in server.allowed_tools
or f"{server.name}-{tool_name}" in server.allowed_tools
)
if server.disallowed_tools:
return tool_name not in server.disallowed_tools
return (
tool_name not in server.disallowed_tools
and f"{server.name}-{tool_name}" not in server.disallowed_tools
)
return True
def validate_allowed_params(
@@ -696,6 +865,64 @@ class MCPServerManager:
},
)
async def _call_openapi_tool_handler(
self,
server: MCPServer,
tool_name: str,
arguments: Dict[str, Any],
) -> CallToolResult:
"""
Call an OpenAPI tool handler directly.
For OpenAPI servers, instead of using MCP protocol, we call the tool handler
that was registered during OpenAPI spec parsing. This handler makes direct
HTTP requests to the API.
Args:
tool_name: The full tool name (with prefix) to call
arguments: Tool arguments to pass to the handler
Returns:
CallToolResult with the response from the API
"""
from mcp.types import TextContent
from litellm.proxy._experimental.mcp_server.tool_registry import (
global_mcp_tool_registry,
)
# Get the tool from the registry
tool = global_mcp_tool_registry.get_tool(f"{server.name}-{tool_name}")
if tool is None:
# Tool not found in registry
error_msg = f"OpenAPI tool {tool_name} not found in registry"
verbose_logger.error(error_msg)
return CallToolResult(
content=[TextContent(type="text", text=error_msg)],
isError=True,
)
try:
# Call the tool handler with the arguments
# The handler is an async function that makes the HTTP request
handler_result = await tool.handler(**arguments)
# Convert the handler result (string response) to CallToolResult format
result = CallToolResult(
content=[TextContent(type="text", text=str(handler_result))],
isError=False,
)
return result
except Exception as e:
error_msg = f"Error calling OpenAPI tool {tool_name}: {str(e)}"
verbose_logger.error(error_msg)
return CallToolResult(
content=[TextContent(type="text", text=error_msg)],
isError=True,
)
async def pre_call_tool_check(
self,
name: str,
@@ -855,95 +1082,109 @@ class MCPServerManager:
server=mcp_server,
)
# Get server-specific auth header if available
server_auth_header: Optional[Union[Dict[str, str], str]] = None
if mcp_server_auth_headers and mcp_server.alias:
server_auth_header = mcp_server_auth_headers.get(mcp_server.alias)
elif mcp_server_auth_headers and mcp_server.server_name:
server_auth_header = mcp_server_auth_headers.get(mcp_server.server_name)
# Prepare tasks for during hooks
tasks = []
if proxy_logging_obj:
# Create synthetic LLM data for during hook processing
from litellm.types.llms.base import HiddenParams
from litellm.types.mcp import MCPDuringCallRequestObject
# Fall back to deprecated mcp_auth_header if no server-specific header found
if server_auth_header is None:
server_auth_header = mcp_auth_header
# oauth2 headers
extra_headers: Optional[Dict[str, str]] = None
if mcp_server.auth_type == MCPAuth.oauth2:
extra_headers = oauth2_headers
if mcp_server.extra_headers and raw_headers:
if extra_headers is None:
extra_headers = {}
for header in mcp_server.extra_headers:
if header in raw_headers:
extra_headers[header] = raw_headers[header]
client = self._create_mcp_client(
server=mcp_server,
mcp_auth_header=server_auth_header,
extra_headers=extra_headers,
)
async with client:
# Use the original tool name (without prefix) for the actual call
call_tool_params = MCPCallToolRequestParams(
name=original_tool_name,
request_obj = MCPDuringCallRequestObject(
tool_name=name,
arguments=arguments,
server_name=server_name_from_prefix,
start_time=start_time.timestamp() if start_time else None,
hidden_params=HiddenParams(),
)
tasks = []
if proxy_logging_obj:
# Create synthetic LLM data for during hook processing
from litellm.types.llms.base import HiddenParams
from litellm.types.mcp import MCPDuringCallRequestObject
request_obj = MCPDuringCallRequestObject(
tool_name=name,
during_hook_kwargs = {
"name": name,
"arguments": arguments,
"server_name": server_name_from_prefix,
"user_api_key_auth": user_api_key_auth,
}
synthetic_llm_data = proxy_logging_obj._convert_mcp_to_llm_format(
request_obj, during_hook_kwargs
)
during_hook_task = asyncio.create_task(
proxy_logging_obj.during_call_hook(
user_api_key_dict=user_api_key_auth,
data=synthetic_llm_data,
call_type="mcp_call", # type: ignore
)
)
tasks.append(during_hook_task)
# For OpenAPI servers, call the tool handler directly instead of via MCP client
if mcp_server.spec_path:
verbose_logger.debug(
f"Calling OpenAPI tool {name} directly via HTTP handler"
)
tasks.append(
asyncio.create_task(
self._call_openapi_tool_handler(mcp_server, name, arguments)
)
)
else:
# For regular MCP servers, use the MCP client
# Get server-specific auth header if available
server_auth_header: Optional[Union[Dict[str, str], str]] = None
if mcp_server_auth_headers and mcp_server.alias:
server_auth_header = mcp_server_auth_headers.get(mcp_server.alias)
elif mcp_server_auth_headers and mcp_server.server_name:
server_auth_header = mcp_server_auth_headers.get(mcp_server.server_name)
# Fall back to deprecated mcp_auth_header if no server-specific header found
if server_auth_header is None:
server_auth_header = mcp_auth_header
# oauth2 headers
extra_headers: Optional[Dict[str, str]] = None
if mcp_server.auth_type == MCPAuth.oauth2:
extra_headers = oauth2_headers
if mcp_server.extra_headers and raw_headers:
if extra_headers is None:
extra_headers = {}
for header in mcp_server.extra_headers:
if header in raw_headers:
extra_headers[header] = raw_headers[header]
client = self._create_mcp_client(
server=mcp_server,
mcp_auth_header=server_auth_header,
extra_headers=extra_headers,
)
async with client:
# Use the original tool name (without prefix) for the actual call
call_tool_params = MCPCallToolRequestParams(
name=original_tool_name,
arguments=arguments,
server_name=server_name_from_prefix,
start_time=start_time.timestamp() if start_time else None,
hidden_params=HiddenParams(),
)
tasks.append(asyncio.create_task(client.call_tool(call_tool_params)))
during_hook_kwargs = {
"name": name,
"arguments": arguments,
"server_name": server_name_from_prefix,
"user_api_key_auth": user_api_key_auth,
}
try:
mcp_responses = await asyncio.gather(*tasks)
synthetic_llm_data = proxy_logging_obj._convert_mcp_to_llm_format(
request_obj, during_hook_kwargs
)
# If proxy_logging_obj is None, the tool call result is at index 0
# If proxy_logging_obj is not None, the tool call result is at index 1 (after the during hook task)
result_index = 1 if proxy_logging_obj else 0
result = mcp_responses[result_index]
during_hook_task = asyncio.create_task(
proxy_logging_obj.during_call_hook(
user_api_key_dict=user_api_key_auth,
data=synthetic_llm_data,
call_type="mcp_call", # type: ignore
)
)
tasks.append(during_hook_task)
tasks.append(asyncio.create_task(client.call_tool(call_tool_params)))
try:
mcp_responses = await asyncio.gather(*tasks)
# If proxy_logging_obj is None, the tool call result is at index 0
# If proxy_logging_obj is not None, the tool call result is at index 1 (after the during hook task)
result_index = 1 if proxy_logging_obj else 0
result = mcp_responses[result_index]
return cast(CallToolResult, result)
except (
BlockedPiiEntityError,
GuardrailRaisedException,
HTTPException,
) as e:
# Re-raise guardrail exceptions to properly fail the MCP call
verbose_logger.error(
f"Guardrail blocked MCP tool call during result check: {str(e)}"
)
raise e
return cast(CallToolResult, result)
except (
BlockedPiiEntityError,
GuardrailRaisedException,
HTTPException,
) as e:
# Re-raise guardrail exceptions to properly fail the MCP call
verbose_logger.error(
f"Guardrail blocked MCP tool call during result check: {str(e)}"
)
raise e
#########################################################
# End of Methods that call the upstream MCP servers
@@ -0,0 +1,236 @@
"""
This module is used to generate MCP tools from OpenAPI specs.
"""
import json
from typing import Any, Dict, Optional
import httpx
from litellm._logging import verbose_logger
from litellm.proxy._experimental.mcp_server.tool_registry import (
global_mcp_tool_registry,
)
# Store the base URL and headers globally
BASE_URL = ""
HEADERS: Dict[str, str] = {}
def load_openapi_spec(filepath: str) -> Dict[str, Any]:
"""Load OpenAPI specification from JSON file."""
with open(filepath, "r") as f:
return json.load(f)
def get_base_url(spec: Dict[str, Any]) -> str:
"""Extract base URL from OpenAPI spec."""
# OpenAPI 3.x
if "servers" in spec and spec["servers"]:
return spec["servers"][0]["url"]
# OpenAPI 2.x (Swagger)
elif "host" in spec:
scheme = spec.get("schemes", ["https"])[0]
base_path = spec.get("basePath", "")
return f"{scheme}://{spec['host']}{base_path}"
return ""
def extract_parameters(operation: Dict[str, Any]) -> tuple:
"""Extract parameter names from OpenAPI operation."""
path_params = []
query_params = []
body_params = []
# OpenAPI 3.x and 2.x parameters
if "parameters" in operation:
for param in operation["parameters"]:
param_name = param["name"]
if param.get("in") == "path":
path_params.append(param_name)
elif param.get("in") == "query":
query_params.append(param_name)
elif param.get("in") == "body":
body_params.append(param_name)
# OpenAPI 3.x requestBody
if "requestBody" in operation:
body_params.append("body")
return path_params, query_params, body_params
def build_input_schema(operation: Dict[str, Any]) -> Dict[str, Any]:
"""Build MCP input schema from OpenAPI operation."""
properties = {}
required = []
# Process parameters
if "parameters" in operation:
for param in operation["parameters"]:
param_name = param["name"]
param_schema = param.get("schema", {})
param_type = param_schema.get("type", "string")
properties[param_name] = {
"type": param_type,
"description": param.get("description", ""),
}
if param.get("required", False):
required.append(param_name)
# Process requestBody (OpenAPI 3.x)
if "requestBody" in operation:
request_body = operation["requestBody"]
content = request_body.get("content", {})
# Try to get JSON schema
if "application/json" in content:
schema = content["application/json"].get("schema", {})
properties["body"] = {
"type": "object",
"description": request_body.get("description", "Request body"),
"properties": schema.get("properties", {}),
}
if request_body.get("required", False):
required.append("body")
return {
"type": "object",
"properties": properties,
"required": required if required else [],
}
def create_tool_function(
path: str,
method: str,
operation: Dict[str, Any],
base_url: str,
headers: Optional[Dict[str, str]] = None,
):
"""Create a tool function for an OpenAPI operation.
Args:
path: API endpoint path
method: HTTP method (get, post, put, delete, patch)
operation: OpenAPI operation object
base_url: Base URL for the API
headers: Optional headers to include in requests (e.g., authentication)
"""
if headers is None:
headers = {}
path_params, query_params, body_params = extract_parameters(operation)
all_params = path_params + query_params + body_params
# Build function signature dynamically
if all_params:
params_str = ", ".join(f"{p}: str = ''" for p in all_params)
else:
params_str = ""
# Create the function code as a string
func_code = f'''
async def tool_function({params_str}) -> str:
"""Dynamically generated tool function."""
url = base_url + path
# Replace path parameters
path_param_names = {path_params}
for param_name in path_param_names:
param_value = locals().get(param_name, "")
if param_value:
url = url.replace("{{" + param_name + "}}", str(param_value))
# Build query params
query_param_names = {query_params}
params = {{}}
for param_name in query_param_names:
param_value = locals().get(param_name, "")
if param_value:
params[param_name] = param_value
# Build request body
body_param_names = {body_params}
json_body = None
if body_param_names:
body_value = locals().get("body", {{}})
if isinstance(body_value, dict):
json_body = body_value
elif body_value:
# If it's a string, try to parse as JSON
import json as json_module
try:
json_body = json_module.loads(body_value) if isinstance(body_value, str) else {{"data": body_value}}
except:
json_body = {{"data": body_value}}
# Make HTTP request
async with httpx.AsyncClient() as client:
if "{method.lower()}" == "get":
response = await client.get(url, params=params, headers=headers)
elif "{method.lower()}" == "post":
response = await client.post(url, params=params, json=json_body, headers=headers)
elif "{method.lower()}" == "put":
response = await client.put(url, params=params, json=json_body, headers=headers)
elif "{method.lower()}" == "delete":
response = await client.delete(url, params=params, headers=headers)
elif "{method.lower()}" == "patch":
response = await client.patch(url, params=params, json=json_body, headers=headers)
else:
return "Unsupported HTTP method: {method}"
return response.text
'''
# Execute the function code to create the actual function
local_vars = {
"httpx": httpx,
"headers": headers,
"base_url": base_url,
"path": path,
"method": method,
}
exec(func_code, local_vars)
return local_vars["tool_function"]
def register_tools_from_openapi(spec: Dict[str, Any], base_url: str):
"""Register MCP tools from OpenAPI specification."""
paths = spec.get("paths", {})
for path, path_item in paths.items():
for method in ["get", "post", "put", "delete", "patch"]:
if method in path_item:
operation = path_item[method]
# Generate tool name
operation_id = operation.get(
"operationId", f"{method}_{path.replace('/', '_')}"
)
tool_name = operation_id.replace(" ", "_").lower()
# Get description
description = operation.get(
"summary", operation.get("description", f"{method.upper()} {path}")
)
# Build input schema
input_schema = build_input_schema(operation)
# Create tool function
tool_func = create_tool_function(path, method, operation, base_url)
tool_func.__name__ = tool_name
tool_func.__doc__ = description
# Register tool with local registry
global_mcp_tool_registry.register_tool(
name=tool_name,
description=description,
input_schema=input_schema,
handler=tool_func,
)
verbose_logger.debug(f"Registered tool: {tool_name}")
@@ -522,6 +522,7 @@ if MCP_AVAILABLE:
verbose_logger.info(
f"Successfully fetched {len(all_tools)} tools total from all MCP servers"
)
return all_tools
async def filter_tools_by_key_team_permissions(
@@ -598,30 +599,7 @@ if MCP_AVAILABLE:
)
# Continue with empty managed tools list instead of failing completely
# Get tools from local registry
local_tools = []
try:
local_tools_raw = global_mcp_tool_registry.list_tools()
# Convert local tools to MCPTool format
for tool in local_tools_raw:
# Convert from litellm.types.mcp_server.tool_registry.MCPTool to mcp.types.Tool
mcp_tool = MCPTool(
name=tool.name,
description=tool.description,
inputSchema=tool.input_schema,
)
local_tools.append(mcp_tool)
except Exception as e:
verbose_logger.exception(
f"Error getting tools from local registry: {str(e)}"
)
# Continue with empty local tools list instead of failing completely
# Combine all tools
all_tools = managed_tools + local_tools
return all_tools
return managed_tools
@client
async def call_mcp_tool(
@@ -682,33 +660,42 @@ if MCP_AVAILABLE:
standard_logging_mcp_tool_call
)
litellm_logging_obj.model = f"MCP: {name}"
# Try managed server tool first (pass the full prefixed name)
# Primary and recommended way to use MCP servers
# Check if tool exists in local registry first (for OpenAPI-based tools)
# These tools are registered with their prefixed names
#########################################################
mcp_server: Optional[MCPServer] = (
global_mcp_server_manager._get_mcp_server_from_tool_name(name)
)
if mcp_server:
standard_logging_mcp_tool_call["mcp_server_cost_info"] = (
mcp_server.mcp_info or {}
).get("mcp_server_cost_info")
response = await _handle_managed_mcp_tool(
name=name, # Pass the full name (potentially prefixed)
arguments=arguments,
user_api_key_auth=user_api_key_auth,
mcp_auth_header=mcp_auth_header,
mcp_server_auth_headers=mcp_server_auth_headers,
oauth2_headers=oauth2_headers,
raw_headers=raw_headers,
litellm_logging_obj=litellm_logging_obj,
)
local_tool = global_mcp_tool_registry.get_tool(name)
if local_tool:
verbose_logger.debug(f"Executing local registry tool: {name}")
response = await _handle_local_mcp_tool(name, arguments)
# Fall back to local tool registry (use original name)
#########################################################
# Deprecated: Local MCP Server Tool
# Try managed MCP server tool (pass the full prefixed name)
# Primary and recommended way to use external MCP servers
#########################################################
else:
response = await _handle_local_mcp_tool(original_tool_name, arguments)
mcp_server: Optional[MCPServer] = (
global_mcp_server_manager._get_mcp_server_from_tool_name(name)
)
if mcp_server:
standard_logging_mcp_tool_call["mcp_server_cost_info"] = (
mcp_server.mcp_info or {}
).get("mcp_server_cost_info")
response = await _handle_managed_mcp_tool(
name=name, # Pass the full name (potentially prefixed)
arguments=arguments,
user_api_key_auth=user_api_key_auth,
mcp_auth_header=mcp_auth_header,
mcp_server_auth_headers=mcp_server_auth_headers,
oauth2_headers=oauth2_headers,
raw_headers=raw_headers,
litellm_logging_obj=litellm_logging_obj,
)
# Fall back to local tool registry with original name (legacy support)
#########################################################
# Deprecated: Local MCP Server Tool
#########################################################
else:
response = await _handle_local_mcp_tool(original_tool_name, arguments)
#########################################################
# Post MCP Tool Call Hook
@@ -780,14 +767,21 @@ if MCP_AVAILABLE:
Handle tool execution for local registry tools
Note: Local tools don't use prefixes, so we use the original name
"""
import inspect
tool = global_mcp_tool_registry.get_tool(name)
if not tool:
raise HTTPException(status_code=404, detail=f"Tool '{name}' not found")
try:
result = tool.handler(**arguments)
# Check if handler is async or sync
if inspect.iscoroutinefunction(tool.handler):
result = await tool.handler(**arguments)
else:
result = tool.handler(**arguments)
return [TextContent(text=str(result), type="text")]
except Exception as e:
verbose_logger.exception(f"Error executing local tool {name}: {str(e)}")
return [TextContent(text=f"Error: {str(e)}", type="text")]
def _get_mcp_servers_in_path(path: str) -> Optional[List[str]]:
@@ -1,6 +1,8 @@
import json
from typing import Any, Callable, Dict, List, Optional
from mcp.types import Tool as MCPToolSDKTool
from litellm._logging import verbose_logger
from litellm.proxy.types_utils.utils import get_instance_fn
from litellm.types.mcp_server.tool_registry import MCPTool
@@ -39,12 +41,30 @@ class MCPToolRegistry:
"""
return self.tools.get(name)
def list_tools(self) -> List[MCPTool]:
def list_tools(self, tool_prefix: Optional[str] = None) -> List[MCPTool]:
"""
List all registered tools
"""
if tool_prefix:
return [
tool
for tool in self.tools.values()
if tool.name.startswith(tool_prefix)
]
return list(self.tools.values())
def convert_tools_to_mcp_sdk_tool_type(
self, tools: List[MCPTool]
) -> List[MCPToolSDKTool]:
return [
MCPToolSDKTool(
name=tool.name,
description=tool.description,
inputSchema=tool.input_schema,
)
for tool in tools
]
def load_tools_from_config(
self, mcp_tools_config: Optional[Dict[str, Any]] = None
) -> None:
+5 -22
View File
@@ -17,28 +17,11 @@ model_list:
api_key: dummy
mcp_servers:
deepwiki_mcp:
url: https://mcp.deepwiki.com/mcp
transport: "http"
auth_type: "none"
allowed_params:
read_wiki_contents: ["status"]
# my_api_mcp:
# url: "http://0.0.0.0:8090"
# spec_path: "/Users/krrishdholakia/Documents/temp_py_folder/example_openapi.json"
# auth_type: none
# allowed_tools: ["getpetbyid", "my_api_mcp-findpetsbystatus"]
# # Configure allowed parameters per tool
# # Key: tool name (with or without prefix)
# # Value: list of allowed parameter names
# allowed_params:
# # Using unprefixed tool name
# "getpetbyid": ["status"]
# # Using prefixed tool name (both formats work)
# "my_api_mcp-findpetsbystatus": ["status", "limit"]
# # Example: allow only specific params for another tool
# # "another_tool": ["param1", "param2"]
my_api_mcp:
url: "http://0.0.0.0:8090"
spec_path: "/Users/krrishdholakia/Documents/temp_py_folder/example_openapi.json"
auth_type: none
allowed_tools: ["getpetbyid", "my_api_mcp-findpetsbystatus"]
litellm_settings:
+39 -1
View File
@@ -4,10 +4,48 @@ Types for the management endpoints
Might include fastapi/proxy requirements.txt related imports
"""
from typing import List
from typing import List, Optional, cast
from fastapi_sso.sso.base import OpenID
from litellm.proxy._types import LitellmUserRoles
def is_valid_litellm_user_role(role_str: str) -> bool:
"""
Check if a string is a valid LitellmUserRoles enum value (case-insensitive).
Args:
role_str: String to validate (e.g., "proxy_admin", "PROXY_ADMIN", "internal_user")
Returns:
True if the string matches a valid LitellmUserRoles value, False otherwise
"""
try:
# Use _value2member_map_ for O(1) lookup, case-insensitive
return role_str.lower() in LitellmUserRoles._value2member_map_
except Exception:
return False
def get_litellm_user_role(role_str: str) -> Optional[LitellmUserRoles]:
"""
Convert a string to a LitellmUserRoles enum if valid (case-insensitive).
Args:
role_str: String to convert (e.g., "proxy_admin", "PROXY_ADMIN", "internal_user")
Returns:
LitellmUserRoles enum if valid, None otherwise
"""
try:
# Use _value2member_map_ for O(1) lookup, case-insensitive
result = LitellmUserRoles._value2member_map_.get(role_str.lower())
return cast(Optional[LitellmUserRoles], result)
except Exception:
return None
class CustomOpenID(OpenID):
team_ids: List[str]
user_role: Optional[LitellmUserRoles] = None
+76 -4
View File
@@ -58,7 +58,7 @@ from litellm.proxy.management_endpoints.sso_helper_utils import (
has_admin_ui_access,
)
from litellm.proxy.management_endpoints.team_endpoints import new_team, team_member_add
from litellm.proxy.management_endpoints.types import CustomOpenID
from litellm.proxy.management_endpoints.types import CustomOpenID, get_litellm_user_role
from litellm.proxy.utils import (
PrismaClient,
ProxyLogging,
@@ -277,6 +277,7 @@ def generic_response_convertor(
last_name=response.get(generic_user_last_name_attribute_name),
provider=response.get(generic_provider_attribute_name),
team_ids=all_teams,
user_role=None,
)
@@ -1145,7 +1146,7 @@ class SSOAuthenticationHandler:
) -> str:
"""
Get the redirect URL for SSO
Note: existing_key is not added to the URL to avoid changing the callback URL.
It should be passed via the state parameter instead.
"""
@@ -1348,7 +1349,7 @@ class SSOAuthenticationHandler:
Checks the request 'source' if a cli state token was passed in
This is used to authenticate through the CLI login flow.
The state parameter format is: {PREFIX}:{key}:{existing_key}
- If existing_key is provided, it's included in the state
- The state parameter is used to pass data through the OAuth flow without changing the callback URL
@@ -1673,22 +1674,49 @@ class MicrosoftSSOHandler:
access_token=microsoft_sso.access_token
)
# Extract app roles from the id_token JWT
app_roles = MicrosoftSSOHandler.get_app_roles_from_id_token(
id_token=microsoft_sso.id_token
)
verbose_proxy_logger.debug(f"Extracted app roles from id_token: {app_roles}")
# Combine groups and app roles
user_role: Optional[LitellmUserRoles] = None
if app_roles:
# Check if any app role is a valid LitellmUserRoles
for role_str in app_roles:
role = get_litellm_user_role(role_str)
if role is not None:
user_role = role
verbose_proxy_logger.debug(
f"Found valid LitellmUserRoles '{role.value}' in app_roles"
)
break
verbose_proxy_logger.debug(
f"Combined team_ids (groups + app roles): {user_team_ids}"
)
# if user is trying to get the raw sso response for debugging, return the raw sso response
if return_raw_sso_response:
original_msft_result[MicrosoftSSOHandler.GRAPH_API_RESPONSE_KEY] = (
user_team_ids
)
original_msft_result["app_roles"] = app_roles
return original_msft_result or {}
result = MicrosoftSSOHandler.openid_from_response(
response=original_msft_result,
team_ids=user_team_ids,
user_role=user_role,
)
return result
@staticmethod
def openid_from_response(
response: Optional[dict], team_ids: List[str]
response: Optional[dict],
team_ids: List[str],
user_role: Optional[LitellmUserRoles],
) -> CustomOpenID:
response = response or {}
verbose_proxy_logger.debug(f"Microsoft SSO Callback Response: {response}")
@@ -1700,10 +1728,54 @@ class MicrosoftSSOHandler:
first_name=response.get("givenName"),
last_name=response.get("surname"),
team_ids=team_ids,
user_role=user_role,
)
verbose_proxy_logger.debug(f"Microsoft SSO OpenID Response: {openid_response}")
return openid_response
@staticmethod
def get_app_roles_from_id_token(id_token: Optional[str]) -> List[str]:
"""
Extract app roles from the Microsoft Entra ID (Azure AD) id_token JWT.
App roles are assigned in the Azure AD Enterprise Application and appear
in the 'roles' claim of the id_token.
Args:
id_token (Optional[str]): The JWT id_token from Microsoft SSO
Returns:
List[str]: List of app role names assigned to the user
"""
if not id_token:
verbose_proxy_logger.debug("No id_token provided for app role extraction")
return []
try:
import jwt
# Decode the JWT without signature verification
# (signature is already verified by fastapi_sso)
decoded_token = jwt.decode(id_token, options={"verify_signature": False})
# Extract roles claim from the token
roles = decoded_token.get("roles", [])
if roles and isinstance(roles, list):
verbose_proxy_logger.debug(
f"Found {len(roles)} app role(s) in id_token: {roles}"
)
return roles
else:
verbose_proxy_logger.debug(
"No app roles found in id_token or roles claim is not a list"
)
return []
except Exception as e:
verbose_proxy_logger.error(f"Error extracting app roles from id_token: {e}")
return []
@staticmethod
async def get_user_groups_from_graph_api(
access_token: Optional[str] = None,
@@ -1,11 +1,36 @@
import os
from typing import Any, Callable, Optional, Union
from litellm._logging import verbose_logger
from litellm.types.secret_managers.get_azure_ad_token_provider import (
AzureCredentialType,
)
def infer_credential_type_from_environment() -> AzureCredentialType:
if (
os.environ.get("AZURE_CLIENT_ID")
and os.environ.get("AZURE_CLIENT_SECRET")
and os.environ.get("AZURE_TENANT_ID")
):
return AzureCredentialType.ClientSecretCredential
elif os.environ.get("AZURE_CLIENT_ID"):
return AzureCredentialType.ManagedIdentityCredential
elif (
os.environ.get("AZURE_CLIENT_ID")
and os.environ.get("AZURE_TENANT_ID")
and os.environ.get("AZURE_CERTIFICATE_PATH")
and os.environ.get("AZURE_CERTIFICATE_PASSWORD")
):
return AzureCredentialType.CertificateCredential
elif os.environ.get("AZURE_CERTIFICATE_PASSWORD"):
return AzureCredentialType.CertificateCredential
elif os.environ.get("AZURE_CERTIFICATE_PATH"):
return AzureCredentialType.CertificateCredential
else:
return AzureCredentialType.DefaultAzureCredential
def get_azure_ad_token_provider(
azure_scope: Optional[str] = None,
azure_credential: Optional[AzureCredentialType] = None,
@@ -42,9 +67,14 @@ def get_azure_ad_token_provider(
)
cred: str = (
azure_credential.value if azure_credential else None
or os.environ.get("AZURE_CREDENTIAL", AzureCredentialType.ClientSecretCredential)
or AzureCredentialType.ClientSecretCredential
azure_credential.value
if azure_credential
else None
or os.environ.get("AZURE_CREDENTIAL")
or infer_credential_type_from_environment()
)
verbose_logger.info(
f"For Azure AD Token Provider, choosing credential type: {cred}"
)
credential: Optional[
Union[
@@ -17,6 +17,7 @@ class MCPServer(BaseModel):
server_name: Optional[str] = None
url: Optional[str] = None
transport: MCPTransportType
spec_path: Optional[str] = None
auth_type: Optional[MCPAuthType] = None
authentication_token: Optional[str] = None
mcp_info: Optional[MCPInfo] = None
+2
View File
@@ -7305,6 +7305,8 @@ class ProviderConfigManager:
return litellm.AzureOpenAIOSeriesResponsesAPIConfig()
else:
return litellm.AzureOpenAIResponsesAPIConfig()
elif litellm.LlmProviders.LITELLM_PROXY == provider:
return litellm.LiteLLMProxyResponsesAPIConfig()
return None
@staticmethod
+33
View File
@@ -12975,6 +12975,39 @@
"supports_vision": true,
"supports_web_search": true
},
"gpt-5-pro-2025-10-06": {
"input_cost_per_token": 1.5e-05,
"input_cost_per_token_batches": 7.5e-06,
"litellm_provider": "openai",
"max_input_tokens": 400000,
"max_output_tokens": 272000,
"max_tokens": 272000,
"mode": "responses",
"output_cost_per_token": 1.2e-04,
"output_cost_per_token_batches": 6e-05,
"supported_endpoints": [
"/v1/batch",
"/v1/responses"
],
"supported_modalities": [
"text",
"image"
],
"supported_output_modalities": [
"text"
],
"supports_function_calling": true,
"supports_native_streaming": false,
"supports_parallel_function_calling": true,
"supports_pdf_input": true,
"supports_prompt_caching": true,
"supports_reasoning": true,
"supports_response_schema": true,
"supports_system_messages": true,
"supports_tool_choice": true,
"supports_vision": true,
"supports_web_search": true
},
"gpt-5-codex": {
"cache_read_input_token_cost": 1.25e-07,
"input_cost_per_token": 1.25e-06,
+34 -20
View File
@@ -267,7 +267,11 @@ def test_gemini_image_generation():
assert len(response.choices[0].message.images) > 0
assert response.choices[0].message.images[0]["image_url"] is not None
assert response.choices[0].message.images[0]["image_url"]["url"] is not None
assert response.choices[0].message.images[0]["image_url"]["url"].startswith("data:image/png;base64,")
assert (
response.choices[0]
.message.images[0]["image_url"]["url"]
.startswith("data:image/png;base64,")
)
def test_gemini_2_5_flash_image_preview():
@@ -772,7 +776,8 @@ def test_system_message_with_no_user_message():
assert response is not None
assert response.choices[0].message.content is not None
def get_current_weather(location, unit="fahrenheit"):
"""Get the current weather in a given location"""
if "tokyo" in location.lower():
@@ -889,9 +894,9 @@ def test_gemini_reasoning_effort_minimal():
# Test with different Gemini models to verify model-specific mapping
test_cases = [
("gemini/gemini-2.5-flash", 1), # Flash: minimum 1 token
("gemini/gemini-2.5-pro", 128), # Pro: minimum 128 tokens
("gemini/gemini-2.5-flash-lite", 512), # Flash-Lite: minimum 512 tokens
("gemini/gemini-2.5-flash", 1), # Flash: minimum 1 token
("gemini/gemini-2.5-pro", 128), # Pro: minimum 128 tokens
("gemini/gemini-2.5-flash-lite", 512), # Flash-Lite: minimum 512 tokens
]
for model, expected_min_budget in test_cases:
@@ -904,24 +909,32 @@ def test_gemini_reasoning_effort_minimal():
"reasoning_effort": "minimal",
},
)
# Verify that the thinking config is set correctly
request_body = raw_request["raw_request_body"]
assert "generationConfig" in request_body, f"Model {model} should have generationConfig"
assert (
"generationConfig" in request_body
), f"Model {model} should have generationConfig"
generation_config = request_body["generationConfig"]
assert "thinkingConfig" in generation_config, f"Model {model} should have thinkingConfig"
assert (
"thinkingConfig" in generation_config
), f"Model {model} should have thinkingConfig"
thinking_config = generation_config["thinkingConfig"]
assert "thinkingBudget" in thinking_config, f"Model {model} should have thinkingBudget"
assert (
"thinkingBudget" in thinking_config
), f"Model {model} should have thinkingBudget"
actual_budget = thinking_config["thinkingBudget"]
assert actual_budget == expected_min_budget, \
f"Model {model} should map 'minimal' to {expected_min_budget} tokens, got {actual_budget}"
assert (
actual_budget == expected_min_budget
), f"Model {model} should map 'minimal' to {expected_min_budget} tokens, got {actual_budget}"
# Verify that includeThoughts is True for minimal reasoning effort
assert thinking_config.get("includeThoughts", True), \
f"Model {model} should have includeThoughts=True for minimal reasoning effort"
assert thinking_config.get(
"includeThoughts", True
), f"Model {model} should have includeThoughts=True for minimal reasoning effort"
# Test with unknown model (should use generic fallback)
try:
@@ -933,13 +946,14 @@ def test_gemini_reasoning_effort_minimal():
"reasoning_effort": "minimal",
},
)
request_body = raw_request["raw_request_body"]
generation_config = request_body["generationConfig"]
thinking_config = generation_config["thinkingConfig"]
# Should use generic fallback (128 tokens)
assert thinking_config["thinkingBudget"] == 128, \
"Unknown model should use generic fallback of 128 tokens"
assert (
thinking_config["thinkingBudget"] == 128
), "Unknown model should use generic fallback of 128 tokens"
except Exception as e:
# If return_raw_request doesn't work for unknown models, that's okay
# The important part is that our known models work correctly
@@ -397,7 +397,7 @@ async def test_async_vertexai_response():
| litellm.vertex_text_models
| litellm.vertex_code_text_models
)
test_models = random.sample(list(test_models), 1)
test_models += list(litellm.vertex_language_models) # always test gemini-pro
for model in test_models:
@@ -504,7 +504,6 @@ async def test_async_vertexai_streaming_response():
pytest.fail(f"An exception occurred: {e}")
@pytest.mark.parametrize("load_pdf", [False]) # True,
@pytest.mark.flaky(retries=3, delay=1)
def test_completion_function_plus_pdf(load_pdf):
@@ -547,6 +546,7 @@ def test_completion_function_plus_pdf(load_pdf):
except Exception as e:
pytest.fail("Got={}".format(str(e)))
def encode_image(image_path):
import base64
@@ -910,7 +910,10 @@ async def test_partner_models_httpx(model, region, sync_mode):
[
("vertex_ai/meta/llama-4-scout-17b-16e-instruct-maas", "us-east5"),
("vertex_ai/qwen/qwen3-coder-480b-a35b-instruct-maas", "us-south1"),
("vertex_ai/mistral-large-2411", "us-central1"), # critical - we had this issue: https://github.com/BerriAI/litellm/issues/13888
(
"vertex_ai/mistral-large-2411",
"us-central1",
), # critical - we had this issue: https://github.com/BerriAI/litellm/issues/13888
("vertex_ai/openai/gpt-oss-20b-maas", "us-central1"),
],
)
@@ -3773,7 +3776,7 @@ def test_vertex_ai_gemini_audio_ogg():
@pytest.mark.asyncio
async def test_vertex_ai_deepseek():
"""Test that deepseek models use the correct v1 API endpoint instead of v1beta1."""
#load_vertex_ai_credentials()
# load_vertex_ai_credentials()
litellm._turn_on_debug()
from litellm.llms.custom_httpx.http_handler import AsyncHTTPHandler
@@ -3786,21 +3789,17 @@ async def test_vertex_ai_deepseek():
{
"message": {
"role": "assistant",
"content": "Hello! How can I help you today?"
"content": "Hello! How can I help you today?",
},
"index": 0,
"finish_reason": "stop"
"finish_reason": "stop",
}
],
"usage": {
"prompt_tokens": 10,
"completion_tokens": 20,
"total_tokens": 30
},
"model": "deepseek-ai/deepseek-r1-0528-maas"
"usage": {"prompt_tokens": 10, "completion_tokens": 20, "total_tokens": 30},
"model": "deepseek-ai/deepseek-r1-0528-maas",
}
mock_response.status_code = 200
with patch.object(client, "post", return_value=mock_response) as mock_post:
response = await acompletion(
model="vertex_ai/deepseek-ai/deepseek-r1-0528-maas",
+53
View File
@@ -2765,3 +2765,56 @@ def test_caching_thinking_args_hit(): # test in memory cache
except Exception as e:
print(f"error occurred: {traceback.format_exc()}")
pytest.fail(f"Error occurred: {e}")
@pytest.mark.asyncio
async def test_cache_key_in_hidden_params_acompletion():
"""
Test that cache_key is present in _hidden_params on cache hits for acompletion.
Validates fix for missing x-litellm-cache-key header on proxy cache hits.
"""
litellm.cache = Cache(
type="redis",
host=os.environ["REDIS_HOST"],
port=os.environ["REDIS_PORT"],
password=os.environ["REDIS_PASSWORD"],
)
unique_content = f"test cache key hidden params {uuid.uuid4()}"
messages = [{"role": "user", "content": unique_content}]
# First call - cache miss
response1 = await litellm.acompletion(
model="gpt-3.5-turbo",
messages=messages,
mock_response="test response",
caching=True,
)
print(f"Response 1 _hidden_params: {response1._hidden_params}")
assert response1._hidden_params.get("cache_hit") is not True
await asyncio.sleep(0.5)
# Second call - cache hit
response2 = await litellm.acompletion(
model="gpt-3.5-turbo",
messages=messages,
mock_response="test response",
caching=True,
)
print(f"Response 2 _hidden_params: {response2._hidden_params}")
# Verify cache hit occurred
assert response2._hidden_params.get("cache_hit") is True
# Verify cache_key is present in _hidden_params
assert "cache_key" in response2._hidden_params
assert response2._hidden_params["cache_key"] is not None
# Verify both responses have same ID (cache hit)
assert response1.id == response2.id
litellm.cache = None
+21
View File
@@ -401,3 +401,24 @@ def test_provider_config_manager_bedrock_converse_like():
# model="gpt-3.5-turbo", provider=LlmProviders(provider)
# )
# _check_provider_config(config, provider)
def test_litellm_proxy_responses_api_config():
"""Test that litellm_proxy provider returns correct Responses API config"""
from litellm.llms.litellm_proxy.responses.transformation import (
LiteLLMProxyResponsesAPIConfig,
)
config = ProviderConfigManager.get_provider_responses_api_config(
model="litellm_proxy/gpt-4",
provider=LlmProviders.LITELLM_PROXY,
)
print(f"config: {config}")
assert config is not None, "Config should not be None for litellm_proxy provider"
assert isinstance(
config, LiteLLMProxyResponsesAPIConfig
), f"Expected LiteLLMProxyResponsesAPIConfig, got {type(config)}"
assert (
config.custom_llm_provider == LlmProviders.LITELLM_PROXY
), "custom_llm_provider should be LITELLM_PROXY"
@@ -114,3 +114,206 @@ def test_openrouter_cache_control_flag_removal():
headers={},
)
assert transformed_request["messages"][0].get("cache_control") is None
def test_openrouter_transform_request_with_cache_control():
"""
Test transform_request moves cache_control from message level to content blocks (string content).
Input:
{
"role": "user",
"content": "what are the key terms...",
"cache_control": {"type": "ephemeral"}
}
Expected Output:
{
"role": "user",
"content": [
{
"type": "text",
"text": "what are the key terms...",
"cache_control": {"type": "ephemeral"}
}
]
}
"""
import json
config = OpenrouterConfig()
messages = [
{
"role": "system",
"content": [
{
"type": "text",
"text": "You are an AI assistant tasked with analyzing legal documents."
},
{
"type": "text",
"text": "Here is the full text of a complex legal agreement"
}
]
},
{
"role": "user",
"content": "what are the key terms and conditions in this agreement?",
"cache_control": {"type": "ephemeral"}
}
]
transformed_request = config.transform_request(
model="openrouter/anthropic/claude-3-5-sonnet-20240620",
messages=messages,
optional_params={},
litellm_params={},
headers={},
)
print("\n=== Transformed Request ===")
print(json.dumps(transformed_request, indent=4, default=str))
assert "messages" in transformed_request
assert len(transformed_request["messages"]) == 2
user_message = transformed_request["messages"][1]
assert user_message["role"] == "user"
assert isinstance(user_message["content"], list)
assert user_message["content"][0]["type"] == "text"
assert user_message["content"][0]["cache_control"] == {"type": "ephemeral"}
def test_openrouter_transform_request_with_cache_control_list_content():
"""
Test transform_request moves cache_control to all content blocks when content is already a list.
Input:
{
"role": "system",
"content": [
{"type": "text", "text": "You are a historian..."},
{"type": "text", "text": "HUGE TEXT BODY"}
],
"cache_control": {"type": "ephemeral"}
}
Expected Output:
{
"role": "system",
"content": [
{
"type": "text",
"text": "You are a historian...",
"cache_control": {"type": "ephemeral"}
},
{
"type": "text",
"text": "HUGE TEXT BODY",
"cache_control": {"type": "ephemeral"}
}
]
}
"""
import json
config = OpenrouterConfig()
messages = [
{
"role": "system",
"content": [
{
"type": "text",
"text": "You are a historian studying the fall of the Roman Empire."
},
{
"type": "text",
"text": "HUGE TEXT BODY"
}
],
"cache_control": {"type": "ephemeral"}
},
{
"role": "user",
"content": "What triggered the collapse?"
}
]
transformed_request = config.transform_request(
model="openrouter/anthropic/claude-3-5-sonnet-20240620",
messages=messages,
optional_params={},
litellm_params={},
headers={},
)
print("\n=== Transformed Request (List Content) ===")
print(json.dumps(transformed_request, indent=4, default=str))
assert "messages" in transformed_request
assert len(transformed_request["messages"]) == 2
system_message = transformed_request["messages"][0]
assert system_message["role"] == "system"
assert isinstance(system_message["content"], list)
assert len(system_message["content"]) == 2
assert system_message["content"][0]["cache_control"] == {"type": "ephemeral"}
assert system_message["content"][1]["cache_control"] == {"type": "ephemeral"}
assert "cache_control" not in system_message
def test_openrouter_transform_request_with_cache_control_gemini():
"""
Test transform_request moves cache_control to content blocks for Gemini models.
Input:
{
"role": "user",
"content": "Analyze this data",
"cache_control": {"type": "ephemeral"}
}
Expected Output:
{
"role": "user",
"content": [
{
"type": "text",
"text": "Analyze this data",
"cache_control": {"type": "ephemeral"}
}
]
}
"""
import json
config = OpenrouterConfig()
messages = [
{
"role": "user",
"content": "Analyze this data",
"cache_control": {"type": "ephemeral"}
}
]
transformed_request = config.transform_request(
model="openrouter/google/gemini-2.0-flash-exp:free",
messages=messages,
optional_params={},
litellm_params={},
headers={},
)
print("\n=== Transformed Request (Gemini) ===")
print(json.dumps(transformed_request, indent=4, default=str))
assert "messages" in transformed_request
assert len(transformed_request["messages"]) == 1
user_message = transformed_request["messages"][0]
assert user_message["role"] == "user"
assert isinstance(user_message["content"], list)
assert user_message["content"][0]["type"] == "text"
assert user_message["content"][0]["cache_control"] == {"type": "ephemeral"}
@@ -1053,7 +1053,7 @@ async def test_get_team_object_permission_with_already_loaded_permission():
from the team object without making an additional DB call.
"""
from litellm.proxy._types import LiteLLM_ObjectPermissionTable, LiteLLM_TeamTable
# Create mock object permission
mock_object_permission = LiteLLM_ObjectPermissionTable(
object_permission_id="perm-123",
@@ -1077,28 +1077,34 @@ async def test_get_team_object_permission_with_already_loaded_permission():
)
# Mock get_team_object to return our team with loaded permission
# Also need to mock prisma_client from proxy_server
mock_prisma = MagicMock()
with patch(
"litellm.proxy._experimental.mcp_server.auth.user_api_key_auth_mcp.get_team_object"
) as mock_get_team:
"litellm.proxy.proxy_server.prisma_client",
mock_prisma,
):
with patch(
"litellm.proxy._experimental.mcp_server.auth.user_api_key_auth_mcp.get_object_permission"
) as mock_get_perm:
mock_get_team.return_value = mock_team_obj
# Call the method
result = await MCPRequestHandler._get_team_object_permission(
mock_user_auth
)
# Assert we got the object permission
assert result == mock_object_permission
assert result.mcp_servers == ["server1", "server2"]
# Verify get_team_object was called
mock_get_team.assert_called_once()
# Verify get_object_permission was NOT called (since it was already loaded)
mock_get_perm.assert_not_called()
"litellm.proxy.auth.auth_checks.get_team_object"
) as mock_get_team:
with patch(
"litellm.proxy.auth.auth_checks.get_object_permission"
) as mock_get_perm:
mock_get_team.return_value = mock_team_obj
# Call the method
result = await MCPRequestHandler._get_team_object_permission(
mock_user_auth
)
# Assert we got the object permission
assert result == mock_object_permission
assert result.mcp_servers == ["server1", "server2"]
# Verify get_team_object was called
mock_get_team.assert_called_once()
# Verify get_object_permission was NOT called (since it was already loaded)
mock_get_perm.assert_not_called()
@pytest.mark.asyncio
@@ -1108,7 +1114,7 @@ async def test_get_team_object_permission_fetches_from_db_when_not_loaded():
is not loaded but object_permission_id exists.
"""
from litellm.proxy._types import LiteLLM_ObjectPermissionTable, LiteLLM_TeamTable
# Create mock object permission (to be returned from DB)
mock_object_permission = LiteLLM_ObjectPermissionTable(
object_permission_id="perm-456",
@@ -1132,35 +1138,41 @@ async def test_get_team_object_permission_fetches_from_db_when_not_loaded():
)
# Mock the methods
# Also need to mock prisma_client from proxy_server
mock_prisma = MagicMock()
with patch(
"litellm.proxy._experimental.mcp_server.auth.user_api_key_auth_mcp.get_team_object"
) as mock_get_team:
"litellm.proxy.proxy_server.prisma_client",
mock_prisma,
):
with patch(
"litellm.proxy._experimental.mcp_server.auth.user_api_key_auth_mcp.get_object_permission"
) as mock_get_perm:
mock_get_team.return_value = mock_team_obj
mock_get_perm.return_value = mock_object_permission
# Call the method
result = await MCPRequestHandler._get_team_object_permission(
mock_user_auth
)
# Assert we got the object permission
assert result == mock_object_permission
assert result.mcp_servers == ["server3", "server4"]
# Verify get_team_object was called
mock_get_team.assert_called_once()
# Verify get_object_permission WAS called (since it wasn't loaded)
mock_get_perm.assert_called_once_with(
object_permission_id="perm-456",
prisma_client=mock.ANY,
user_api_key_cache=mock.ANY,
parent_otel_span=mock_user_auth.parent_otel_span,
proxy_logging_obj=mock.ANY,
)
"litellm.proxy.auth.auth_checks.get_team_object"
) as mock_get_team:
with patch(
"litellm.proxy.auth.auth_checks.get_object_permission"
) as mock_get_perm:
mock_get_team.return_value = mock_team_obj
mock_get_perm.return_value = mock_object_permission
# Call the method
result = await MCPRequestHandler._get_team_object_permission(
mock_user_auth
)
# Assert we got the object permission
assert result == mock_object_permission
assert result.mcp_servers == ["server3", "server4"]
# Verify get_team_object was called
mock_get_team.assert_called_once()
# Verify get_object_permission WAS called (since it wasn't loaded)
mock_get_perm.assert_called_once_with(
object_permission_id="perm-456",
prisma_client=mock.ANY,
user_api_key_cache=mock.ANY,
parent_otel_span=mock_user_auth.parent_otel_span,
proxy_logging_obj=mock.ANY,
)
@pytest.mark.asyncio
@@ -1170,7 +1182,7 @@ async def test_get_allowed_mcp_servers_for_team_uses_helper():
helper which handles both loaded and unloaded object_permission cases.
"""
from litellm.proxy._types import LiteLLM_ObjectPermissionTable
# Create mock object permission with servers and access groups
mock_object_permission = LiteLLM_ObjectPermissionTable(
object_permission_id="perm-789",
@@ -1001,7 +1001,7 @@ async def test_list_tools_with_no_tool_permissions_shows_all():
async def test_list_tools_strips_prefix_when_matching_permissions():
"""
Test that tool permission filtering correctly strips prefixes from tool names.
Tools from MCP servers are prefixed (e.g., "GITMCP-fetch_litellm_documentation"),
but allowed tools in DB are stored without prefix (e.g., "fetch_litellm_documentation").
The filtering should strip the prefix before comparing.
@@ -1056,7 +1056,9 @@ async def test_list_tools_strips_prefix_when_matching_permissions():
tool1.inputSchema = {}
tool2 = MagicMock()
tool2.name = "GITMCP-search_litellm_documentation" # Prefixed, not in allowed list
tool2.name = (
"GITMCP-search_litellm_documentation" # Prefixed, not in allowed list
)
tool2.description = "Search docs"
tool2.inputSchema = {}
@@ -1093,3 +1095,76 @@ async def test_list_tools_strips_prefix_when_matching_permissions():
"GITMCP-fetch_litellm_documentation",
"GITMCP-search_litellm_code",
]
def test_filter_tools_by_allowed_tools():
"""Test that filter_tools_by_allowed_tools filters tools correctly"""
from mcp.types import Tool
from litellm.proxy._experimental.mcp_server.server import (
filter_tools_by_allowed_tools,
)
from litellm.types.mcp import MCPTransport
from litellm.types.mcp_server.mcp_server_manager import MCPServer
mcp_server = MCPServer(
server_id="my_api_mcp",
name="my_api_mcp",
alias="my_api_mcp",
transport=MCPTransport.http,
allowed_tools=["getpetbyid", "my_api_mcp-findpetsbystatus"],
disallowed_tools=None,
)
tools_to_return = [
Tool(
name="my_api_mcp-getpetbyid",
title=None,
description="Find pet by ID",
inputSchema={
"type": "object",
"properties": {"petId": {"type": "integer", "description": ""}},
"required": ["petId"],
},
outputSchema=None,
annotations=None,
),
Tool(
name="my_api_mcp-findpetsbystatus",
title=None,
description="Finds Pets by status",
inputSchema={
"type": "object",
"properties": {"status": {"type": "string", "description": ""}},
"required": ["status"],
},
outputSchema=None,
annotations=None,
),
Tool(
name="my_api_mcp-addpet",
title=None,
description="Add a new pet to the store",
inputSchema={
"type": "object",
"properties": {
"body": {
"type": "object",
"description": "Request body",
"properties": {
"name": {"type": "string"},
"status": {"type": "string"},
},
}
},
"required": ["body"],
},
outputSchema=None,
annotations=None,
),
]
filtered_tools = filter_tools_by_allowed_tools(tools_to_return, mcp_server)
assert len(filtered_tools) == 2
assert filtered_tools[0].name == "my_api_mcp-getpetbyid"
assert filtered_tools[1].name == "my_api_mcp-findpetsbystatus"
@@ -1150,6 +1150,78 @@ class TestMCPServerManager:
user_api_key_auth=user_auth,
)
@pytest.mark.asyncio
async def test_allowed_tools_with_mixed_prefixed_and_unprefixed_names(self):
"""
Test that allowed_tools works with both unprefixed and prefixed tool names.
This tests the scenario where allowed_tools = ["getpetbyid", "my_api_mcp-findpetsbystatus"]
Both getpetbyid (unprefixed) and findpetsbystatus (called unprefixed but allowed via prefix) should work.
"""
manager = MCPServerManager()
# Create server with mixed prefixed/unprefixed allowed_tools
server = MCPServer(
server_id="my_api_mcp",
name="my_api_mcp",
transport=MCPTransport.stdio,
allowed_tools=["getpetbyid", "my_api_mcp-findpetsbystatus"],
disallowed_tools=None,
)
# Mock dependencies - set object_permission and object_permission_id to None
# so permission checks return None (no restrictions)
user_api_key_auth = MagicMock()
user_api_key_auth.object_permission = None
user_api_key_auth.object_permission_id = None
proxy_logging_obj = MagicMock()
# Mock the async methods that pre_call_tool_check calls
proxy_logging_obj._create_mcp_request_object_from_kwargs = MagicMock(
return_value={}
)
proxy_logging_obj._convert_mcp_to_llm_format = MagicMock(return_value={})
proxy_logging_obj.pre_call_hook = AsyncMock(return_value={})
# Test 1: Call getpetbyid (unprefixed in allowed_tools) - should succeed
await manager.pre_call_tool_check(
name="getpetbyid",
arguments={"petId": "1"},
server_name_from_prefix="my_api_mcp",
user_api_key_auth=user_api_key_auth,
proxy_logging_obj=proxy_logging_obj,
server=server,
)
# Test 2: Call findpetsbystatus (prefixed in allowed_tools as "my_api_mcp-findpetsbystatus") - should succeed
await manager.pre_call_tool_check(
name="findpetsbystatus",
arguments={"status": "available"},
server_name_from_prefix="my_api_mcp",
user_api_key_auth=user_api_key_auth,
proxy_logging_obj=proxy_logging_obj,
server=server,
)
# Test 3: Call a tool that's not in allowed_tools - should fail
with pytest.raises(HTTPException) as exc_info:
await manager.pre_call_tool_check(
name="deletepet",
arguments={"petId": "1"},
server_name_from_prefix="my_api_mcp",
user_api_key_auth=user_api_key_auth,
proxy_logging_obj=proxy_logging_obj,
server=server,
)
assert exc_info.value.status_code == 403
assert (
"Tool deletepet is not allowed for server my_api_mcp"
in exc_info.value.detail["error"]
)
assert (
"Contact proxy admin to allow this tool" in exc_info.value.detail["error"]
)
if __name__ == "__main__":
pytest.main([__file__])
@@ -2,7 +2,6 @@ import asyncio
import json
import os
import sys
from litellm._uuid import uuid
from typing import Optional, cast
from unittest.mock import AsyncMock, MagicMock, patch
@@ -10,6 +9,8 @@ import pytest
from fastapi import HTTPException
from fastapi.testclient import TestClient
from litellm._uuid import uuid
sys.path.insert(
0, os.path.abspath("../../../")
) # Adds the parent directory to the system path
@@ -412,8 +413,10 @@ async def test_new_team_with_mcp_tool_permissions(mock_db_client, mock_admin_aut
)
# Verify mcp_tool_permissions was stored
import json
assert "mcp_tool_permissions" in created_permission_data
assert created_permission_data["mcp_tool_permissions"] == {
# mcp_tool_permissions is stored as a JSON string
assert json.loads(created_permission_data["mcp_tool_permissions"]) == {
"server_a": ["read_wiki_structure", "read_wiki_contents"],
"server_b": ["ask_question"],
}
@@ -214,3 +214,32 @@ class TestGetAzureAdTokenProvider:
# Test that the returned callable works
token = result()
assert token == "mock-certificate-token"
@patch.dict(os.environ, {}, clear=True) # Clear all environment variables
@patch("azure.identity.get_bearer_token_provider")
@patch("azure.identity.DefaultAzureCredential")
def test_get_azure_ad_token_provider_defaults_to_default_azure_credential(
self, mock_default_azure_credential, mock_get_bearer_token_provider
):
"""Test get_azure_ad_token_provider defaults to DefaultAzureCredential when no credentials are present."""
# Mock the Azure identity credential instance
mock_credential_instance = MagicMock()
mock_default_azure_credential.return_value = mock_credential_instance
# Mock the bearer token provider
mock_token_provider = MagicMock(return_value="mock-default-token")
mock_get_bearer_token_provider.return_value = mock_token_provider
# Call the function
result = get_azure_ad_token_provider()
# Assertions
assert callable(result)
mock_default_azure_credential.assert_called_once_with()
mock_get_bearer_token_provider.assert_called_once_with(
mock_credential_instance, "https://cognitiveservices.azure.com/.default"
)
# Test that the returned callable works
token = result()
assert token == "mock-default-token"
+109
View File
@@ -0,0 +1,109 @@
from litellm._redis import get_redis_url_from_environment
import os
import pytest
def test_get_redis_url_from_environment_single_url(monkeypatch):
"""Test when REDIS_URL is directly provided"""
# Set the environment variable
monkeypatch.setenv("REDIS_URL", "redis://redis-server:6379/0")
# Call the function to get the Redis URL
redis_url = get_redis_url_from_environment()
# Assert that the returned URL matches the expected value
assert redis_url == "redis://redis-server:6379/0"
def test_get_redis_url_from_environment_host_port(monkeypatch):
"""Test when REDIS_HOST and REDIS_PORT are provided"""
# Set the environment variables
monkeypatch.setenv("REDIS_HOST", "redis-server")
monkeypatch.setenv("REDIS_PORT", "6379")
# Call the function to get the Redis URL
redis_url = get_redis_url_from_environment()
# Assert that the returned URL matches the expected value
assert redis_url == "redis://redis-server:6379"
def test_get_redis_url_from_environment_with_ssl(monkeypatch):
"""Test when SSL is enabled"""
# Set the environment variables
monkeypatch.setenv("REDIS_HOST", "redis-server")
monkeypatch.setenv("REDIS_PORT", "6379")
monkeypatch.setenv("REDIS_SSL", "true")
# Call the function to get the Redis URL
redis_url = get_redis_url_from_environment()
# Assert that the returned URL uses rediss:// protocol
assert redis_url == "rediss://redis-server:6379"
def test_get_redis_url_from_environment_with_username_password(monkeypatch):
"""Test when username and password are provided"""
# Set the environment variables
monkeypatch.setenv("REDIS_HOST", "redis-server")
monkeypatch.setenv("REDIS_PORT", "6379")
monkeypatch.setenv("REDIS_USERNAME", "user")
monkeypatch.setenv("REDIS_PASSWORD", "password")
# Call the function to get the Redis URL
redis_url = get_redis_url_from_environment()
# Assert that the returned URL includes username:password@
assert redis_url == "redis://user:password@redis-server:6379"
def test_get_redis_url_from_environment_with_password_only(monkeypatch):
"""Test when only password is provided"""
# Set the environment variables
monkeypatch.setenv("REDIS_HOST", "redis-server")
monkeypatch.setenv("REDIS_PORT", "6379")
monkeypatch.setenv("REDIS_PASSWORD", "password")
# Call the function to get the Redis URL
redis_url = get_redis_url_from_environment()
# Assert that the returned URL includes :password@
assert redis_url == "redis://password@redis-server:6379"
def test_get_redis_url_from_environment_with_all_options(monkeypatch):
"""Test when all options are provided"""
# Set the environment variables
monkeypatch.setenv("REDIS_HOST", "redis-server")
monkeypatch.setenv("REDIS_PORT", "6379")
monkeypatch.setenv("REDIS_USERNAME", "user")
monkeypatch.setenv("REDIS_PASSWORD", "password")
monkeypatch.setenv("REDIS_SSL", "true")
# Call the function to get the Redis URL
redis_url = get_redis_url_from_environment()
# Assert that the returned URL includes all components
assert redis_url == "rediss://user:password@redis-server:6379"
def test_get_redis_url_from_environment_missing_host_port(monkeypatch):
"""Test error when required variables are missing"""
# Make sure these environment variables don't exist
monkeypatch.delenv("REDIS_URL", raising=False)
monkeypatch.delenv("REDIS_HOST", raising=False)
monkeypatch.delenv("REDIS_PORT", raising=False)
# Call the function and expect a ValueError
with pytest.raises(ValueError) as excinfo:
get_redis_url_from_environment()
# Check the error message
assert "Either 'REDIS_URL' or both 'REDIS_HOST' and 'REDIS_PORT' must be specified" in str(excinfo.value)
def test_get_redis_url_from_environment_missing_port(monkeypatch):
"""Test error when only REDIS_HOST is provided but REDIS_PORT is missing"""
# Make sure REDIS_URL doesn't exist and set only REDIS_HOST
monkeypatch.delenv("REDIS_URL", raising=False)
monkeypatch.delenv("REDIS_PORT", raising=False)
monkeypatch.setenv("REDIS_HOST", "redis-server")
# Call the function and expect a ValueError
with pytest.raises(ValueError) as excinfo:
get_redis_url_from_environment()
# Check the error message
assert "Either 'REDIS_URL' or both 'REDIS_HOST' and 'REDIS_PORT' must be specified" in str(excinfo.value)
@@ -0,0 +1,84 @@
"""
Unit test for LiteLLM Proxy Responses API configuration.
"""
import pytest
from litellm.types.utils import LlmProviders
from litellm.utils import ProviderConfigManager
def test_litellm_proxy_responses_api_config():
"""Test that litellm_proxy provider returns correct Responses API config"""
from litellm.llms.litellm_proxy.responses.transformation import (
LiteLLMProxyResponsesAPIConfig,
)
config = ProviderConfigManager.get_provider_responses_api_config(
model="litellm_proxy/gpt-4",
provider=LlmProviders.LITELLM_PROXY,
)
print(f"config: {config}")
assert config is not None, "Config should not be None for litellm_proxy provider"
assert isinstance(
config, LiteLLMProxyResponsesAPIConfig
), f"Expected LiteLLMProxyResponsesAPIConfig, got {type(config)}"
assert (
config.custom_llm_provider == LlmProviders.LITELLM_PROXY
), "custom_llm_provider should be LITELLM_PROXY"
def test_litellm_proxy_responses_api_config_get_complete_url():
"""Test that get_complete_url works correctly"""
import os
from litellm.llms.litellm_proxy.responses.transformation import (
LiteLLMProxyResponsesAPIConfig,
)
config = LiteLLMProxyResponsesAPIConfig()
# Test with explicit api_base
url = config.get_complete_url(
api_base="https://my-proxy.example.com",
litellm_params={},
)
assert url == "https://my-proxy.example.com/responses"
# Test with trailing slash
url = config.get_complete_url(
api_base="https://my-proxy.example.com/",
litellm_params={},
)
assert url == "https://my-proxy.example.com/responses"
# Test that it raises error when api_base is None and env var is not set
if "LITELLM_PROXY_API_BASE" in os.environ:
del os.environ["LITELLM_PROXY_API_BASE"]
with pytest.raises(ValueError, match="api_base not set"):
config.get_complete_url(api_base=None, litellm_params={})
def test_litellm_proxy_responses_api_config_inherits_from_openai():
"""Test that LiteLLMProxyResponsesAPIConfig extends OpenAI config properly"""
from litellm.llms.litellm_proxy.responses.transformation import (
LiteLLMProxyResponsesAPIConfig,
)
from litellm.llms.openai.responses.transformation import (
OpenAIResponsesAPIConfig,
)
config = LiteLLMProxyResponsesAPIConfig()
# Should inherit from OpenAI config
assert isinstance(config, OpenAIResponsesAPIConfig)
# Should have the correct provider set
assert config.custom_llm_provider == LlmProviders.LITELLM_PROXY
if __name__ == "__main__":
test_litellm_proxy_responses_api_config()
test_litellm_proxy_responses_api_config_get_complete_url()
test_litellm_proxy_responses_api_config_inherits_from_openai()
print("All tests passed!")
+2
View File
@@ -0,0 +1,2 @@
NODE_ENV=development
NEXT_PUBLIC_BASE_URL=""
+2
View File
@@ -0,0 +1,2 @@
NODE_ENV=production
NEXT_PUBLIC_BASE_URL="ui/"
@@ -0,0 +1,17 @@
"use client";
import APIRef from "@/components/api_ref";
import { useState } from "react";
interface ProxySettings {
PROXY_BASE_URL: string;
PROXY_LOGOUT_URL: string;
}
const APIReferencePage = () => {
const [proxySettings, setProxySettings] = useState<ProxySettings>({ PROXY_BASE_URL: "", PROXY_LOGOUT_URL: "" });
return <APIRef proxySettings={proxySettings} />;
};
export default APIReferencePage;
@@ -0,0 +1,401 @@
"use client";
import { Layout, Menu, ConfigProvider } from "antd";
import {
KeyOutlined,
PlayCircleOutlined,
BlockOutlined,
BarChartOutlined,
TeamOutlined,
BankOutlined,
UserOutlined,
SettingOutlined,
ApiOutlined,
AppstoreOutlined,
DatabaseOutlined,
FileTextOutlined,
LineChartOutlined,
SafetyOutlined,
ExperimentOutlined,
ToolOutlined,
TagsOutlined,
} from "@ant-design/icons";
// import {
// all_admin_roles,
// rolesWithWriteAccess,
// internalUserRoles,
// isAdminRole,
// } from "../utils/roles";
// import UsageIndicator from "./usage_indicator";
import * as React from "react";
import { useRouter, usePathname } from "next/navigation";
import { all_admin_roles, internalUserRoles, isAdminRole, rolesWithWriteAccess } from "@/utils/roles";
import UsageIndicator from "@/components/usage_indicator";
const { Sider } = Layout;
// -------- Types --------
interface SidebarProps {
accessToken: string | null;
userRole: string;
/** Fallback selection id (legacy), used if path can't be matched */
defaultSelectedKey: string;
collapsed?: boolean;
}
interface MenuItemCfg {
key: string;
page: string; // legacy id; we map this to a path below
label: string;
roles?: string[];
children?: MenuItemCfg[];
icon?: React.ReactNode;
}
/** ---------- Base URL helpers ---------- */
/**
* Normalizes NEXT_PUBLIC_BASE_URL to either "/" or "/ui/" (always with a trailing slash).
* Supported env values: "" or "ui/".
*/
const getBasePath = () => {
const raw = process.env.NEXT_PUBLIC_BASE_URL ?? "";
const trimmed = raw.replace(/^\/+|\/+$/g, ""); // strip leading/trailing slashes
return trimmed ? `/${trimmed}/` : "/"; // ensure trailing slash
};
/** Map legacy `page` ids to real app routes (relative, no leading slash). */
const routeFor = (slug: string): string => {
switch (slug) {
// top level
case "api-keys":
return "virtual-keys";
case "llm-playground":
return "test-key";
case "models":
return "models-and-endpoints";
case "new_usage":
return "usage";
case "teams":
return "teams";
case "organizations":
return "organizations";
case "users":
return "users";
case "api_ref":
return "api-reference";
case "model-hub-table":
// If you intend the newer in-dashboard page, use "model-hub".
return "model-hub";
case "logs":
return "logs";
case "guardrails":
return "guardrails";
// tools
case "mcp-servers":
return "tools/mcp-servers";
case "vector-stores":
return "tools/vector-stores";
// experimental
case "caching":
return "experimental/caching";
case "prompts":
return "experimental/prompts";
case "budgets":
return "experimental/budgets";
case "transform-request":
return "experimental/api-playground";
case "tag-management":
return "experimental/tag-management";
case "usage": // "Old Usage"
return "experimental/old-usage";
// settings
case "general-settings":
return "settings/router-settings";
case "settings": // "Logging & Alerts"
return "settings/logging-and-alerts";
case "admin-panel":
return "settings/admin-settings";
case "ui-theme":
return "settings/ui-theme";
default:
// treat as already a relative path
return slug.replace(/^\/+/, "");
}
};
/** Prefix base path ("/" or "/ui/") */
const toHref = (slugOrPath: string) => {
const base = getBasePath(); // "/" or "/ui/"
const rel = routeFor(slugOrPath).replace(/^\/+|\/+$/g, "");
return `${base}${rel}`;
};
const Sidebar2: React.FC<SidebarProps> = ({ accessToken, userRole, defaultSelectedKey, collapsed = false }) => {
const router = useRouter();
const pathname = usePathname() || "/";
// ----- Menu config (unchanged labels/icons; same appearance) -----
const menuItems: MenuItemCfg[] = [
{ key: "1", page: "api-keys", label: "Virtual Keys", icon: <KeyOutlined style={{ fontSize: 18 }} /> },
{
key: "3",
page: "llm-playground",
label: "Test Key",
icon: <PlayCircleOutlined style={{ fontSize: 18 }} />,
roles: rolesWithWriteAccess,
},
{
key: "2",
page: "models",
label: "Models + Endpoints",
icon: <BlockOutlined style={{ fontSize: 18 }} />,
roles: rolesWithWriteAccess,
},
{
key: "12",
page: "new_usage",
label: "Usage",
icon: <BarChartOutlined style={{ fontSize: 18 }} />,
roles: [...all_admin_roles, ...internalUserRoles],
},
{ key: "6", page: "teams", label: "Teams", icon: <TeamOutlined style={{ fontSize: 18 }} /> },
{
key: "17",
page: "organizations",
label: "Organizations",
icon: <BankOutlined style={{ fontSize: 18 }} />,
roles: all_admin_roles,
},
{
key: "5",
page: "users",
label: "Internal Users",
icon: <UserOutlined style={{ fontSize: 18 }} />,
roles: all_admin_roles,
},
{ key: "14", page: "api_ref", label: "API Reference", icon: <ApiOutlined style={{ fontSize: 18 }} /> },
{
key: "16",
page: "model-hub-table",
label: "Model Hub",
icon: <AppstoreOutlined style={{ fontSize: 18 }} />,
},
{ key: "15", page: "logs", label: "Logs", icon: <LineChartOutlined style={{ fontSize: 18 }} /> },
{
key: "11",
page: "guardrails",
label: "Guardrails",
icon: <SafetyOutlined style={{ fontSize: 18 }} />,
roles: all_admin_roles,
},
{
key: "26",
page: "tools",
label: "Tools",
icon: <ToolOutlined style={{ fontSize: 18 }} />,
children: [
{ key: "18", page: "mcp-servers", label: "MCP Servers", icon: <ToolOutlined style={{ fontSize: 18 }} /> },
{
key: "21",
page: "vector-stores",
label: "Vector Stores",
icon: <DatabaseOutlined style={{ fontSize: 18 }} />,
roles: all_admin_roles,
},
],
},
{
key: "experimental",
page: "experimental",
label: "Experimental",
icon: <ExperimentOutlined style={{ fontSize: 18 }} />,
children: [
{
key: "9",
page: "caching",
label: "Caching",
icon: <DatabaseOutlined style={{ fontSize: 18 }} />,
roles: all_admin_roles,
},
{
key: "25",
page: "prompts",
label: "Prompts",
icon: <FileTextOutlined style={{ fontSize: 18 }} />,
roles: all_admin_roles,
},
{
key: "10",
page: "budgets",
label: "Budgets",
icon: <BankOutlined style={{ fontSize: 18 }} />,
roles: all_admin_roles,
},
{
key: "20",
page: "transform-request",
label: "API Playground",
icon: <ApiOutlined style={{ fontSize: 18 }} />,
roles: [...all_admin_roles, ...internalUserRoles],
},
{
key: "19",
page: "tag-management",
label: "Tag Management",
icon: <TagsOutlined style={{ fontSize: 18 }} />,
roles: all_admin_roles,
},
{ key: "4", page: "usage", label: "Old Usage", icon: <BarChartOutlined style={{ fontSize: 18 }} /> },
],
},
{
key: "settings",
page: "settings",
label: "Settings",
icon: <SettingOutlined style={{ fontSize: 18 }} />,
roles: all_admin_roles,
children: [
{
key: "11",
page: "general-settings",
label: "Router Settings",
icon: <SettingOutlined style={{ fontSize: 18 }} />,
roles: all_admin_roles,
},
{
key: "8",
page: "settings",
label: "Logging & Alerts",
icon: <SettingOutlined style={{ fontSize: 18 }} />,
roles: all_admin_roles,
},
{
key: "13",
page: "admin-panel",
label: "Admin Settings",
icon: <SettingOutlined style={{ fontSize: 18 }} />,
roles: all_admin_roles,
},
{
key: "14",
page: "ui-theme",
label: "UI Theme",
icon: <SettingOutlined style={{ fontSize: 18 }} />,
roles: all_admin_roles,
},
],
},
];
// ----- Filter by role without mutating originals -----
const filteredMenuItems = React.useMemo<MenuItemCfg[]>(() => {
return menuItems
.filter((item) => !item.roles || item.roles.includes(userRole))
.map((item) => ({
...item,
children: item.children ? item.children.filter((c) => !c.roles || c.roles.includes(userRole)) : undefined,
}));
}, [userRole]);
// ----- Compute selected key from current path -----
const selectedMenuKey = React.useMemo(() => {
const base = getBasePath();
// strip base prefix and leading slash -> "virtual-keys", "tools/mcp-servers", etc.
const rel = pathname.startsWith(base) ? pathname.slice(base.length) : pathname.replace(/^\/+/, "");
const relLower = rel.toLowerCase();
const matchesPath = (slug: string) => {
const route = routeFor(slug).toLowerCase();
return relLower === route || relLower.startsWith(`${route}/`);
};
// search top-level
for (const item of filteredMenuItems) {
if (!item.children && matchesPath(item.page)) return item.key;
if (item.children) {
for (const child of item.children) {
if (matchesPath(child.page)) return child.key;
}
}
}
// fallback to legacy defaultSelectedKey mapping
const fallback = filteredMenuItems.find((i) => i.page === defaultSelectedKey)?.key;
if (fallback) return fallback;
for (const item of filteredMenuItems) {
if (item.children?.some((c) => c.page === defaultSelectedKey)) {
const child = item.children.find((c) => c.page === defaultSelectedKey)!;
return child.key;
}
}
return "1";
}, [pathname, filteredMenuItems, defaultSelectedKey]);
// ----- Navigation -----
const goTo = (slug: string) => {
const href = toHref(slug);
router.push(href);
};
return (
<Layout style={{ minHeight: "100vh" }}>
<Sider
theme="light"
width={220}
collapsed={collapsed}
collapsedWidth={80}
collapsible
trigger={null}
style={{
transition: "all 0.3s cubic-bezier(0.4, 0, 0.2, 1)",
position: "relative",
}}
>
<ConfigProvider
theme={{
components: {
Menu: {
iconSize: 18,
fontSize: 14,
},
},
}}
>
<Menu
mode="inline"
selectedKeys={[selectedMenuKey]}
defaultOpenKeys={collapsed ? [] : ["llm-tools"]} // kept to preserve original appearance
inlineCollapsed={collapsed}
className="custom-sidebar-menu"
style={{
borderRight: 0,
backgroundColor: "transparent",
fontSize: "14px",
}}
items={filteredMenuItems.map((item) => ({
key: item.key,
icon: item.icon,
label: item.label,
children: item.children?.map((child) => ({
key: child.key,
icon: child.icon,
label: child.label,
onClick: () => goTo(child.page),
})),
onClick: !item.children ? () => goTo(item.page) : undefined,
}))}
/>
</ConfigProvider>
{isAdminRole(userRole) && !collapsed && <UsageIndicator accessToken={accessToken} width={220} />}
</Sider>
</Layout>
);
};
export default Sidebar2;
@@ -0,0 +1,29 @@
import useFeatureFlags from "@/hooks/useFeatureFlags";
import Sidebar from "@/components/leftnav";
import Sidebar2 from "@/app/(dashboard)/components/Sidebar2";
import useAuthorized from "@/app/(dashboard)/hooks/useAuthorized";
interface SidebarProviderProps {
defaultSelectedKey: string;
setPage: (newPage: string) => void;
sidebarCollapsed: boolean;
}
const SidebarProvider = ({ setPage, defaultSelectedKey, sidebarCollapsed }: SidebarProviderProps) => {
const { refactoredUIFlag } = useFeatureFlags();
const { accessToken, userRole } = useAuthorized();
return refactoredUIFlag ? (
<Sidebar2 accessToken={accessToken} defaultSelectedKey={defaultSelectedKey} userRole={userRole} />
) : (
<Sidebar
accessToken={accessToken}
setPage={setPage}
userRole={userRole}
defaultSelectedKey={defaultSelectedKey}
collapsed={sidebarCollapsed}
/>
);
};
export default SidebarProvider;
@@ -0,0 +1,12 @@
"use client";
import TransformRequestPanel from "@/components/transform_request";
import useAuthorized from "@/app/(dashboard)/hooks/useAuthorized";
const APIPlaygroundPage = () => {
const { accessToken } = useAuthorized();
return <TransformRequestPanel accessToken={accessToken} />;
};
export default APIPlaygroundPage;
@@ -0,0 +1,12 @@
"use client";
import BudgetPanel from "@/components/budgets/budget_panel";
import useAuthorized from "@/app/(dashboard)/hooks/useAuthorized";
const BudgetsPage = () => {
const { accessToken } = useAuthorized();
return <BudgetPanel accessToken={accessToken} />;
};
export default BudgetsPage;
@@ -0,0 +1,20 @@
"use client";
import CacheDashboard from "@/components/cache_dashboard";
import useAuthorized from "@/app/(dashboard)/hooks/useAuthorized";
const CachingPage = () => {
const { token, accessToken, userRole, userId, premiumUser } = useAuthorized();
return (
<CacheDashboard
accessToken={accessToken}
token={token}
userRole={userRole}
userID={userId}
premiumUser={premiumUser}
/>
);
};
export default CachingPage;
@@ -0,0 +1,23 @@
"use client";
import Usage from "@/components/usage";
import useAuthorized from "@/app/(dashboard)/hooks/useAuthorized";
import { useState } from "react";
const OldUsagePage = () => {
const { accessToken, token, userRole, userId, premiumUser } = useAuthorized();
const [keys, setKeys] = useState<null | any[]>([]);
return (
<Usage
accessToken={accessToken}
token={token}
userRole={userRole}
userID={userId}
keys={keys}
premiumUser={premiumUser}
/>
);
};
export default OldUsagePage;
@@ -0,0 +1,12 @@
"use client";
import PromptsPanel from "@/components/prompts";
import useAuthorized from "@/app/(dashboard)/hooks/useAuthorized";
const PromptsPage = () => {
const { accessToken } = useAuthorized();
return <PromptsPanel accessToken={accessToken} />;
};
export default PromptsPage;
@@ -0,0 +1,12 @@
"use client";
import TagManagement from "@/components/tag_management";
import useAuthorized from "@/app/(dashboard)/hooks/useAuthorized";
const TagManagementPage = () => {
const { accessToken, userId, userRole } = useAuthorized();
return <TagManagement accessToken={accessToken} userID={userId} userRole={userRole} />;
};
export default TagManagementPage;
@@ -0,0 +1,12 @@
"use client";
import GuardrailsPanel from "@/components/guardrails";
import useAuthorized from "@/app/(dashboard)/hooks/useAuthorized";
const GuardrailsPage = () => {
const { accessToken } = useAuthorized();
return <GuardrailsPanel accessToken={accessToken} />;
};
export default GuardrailsPage;
@@ -0,0 +1,45 @@
"use client";
import { useEffect, useMemo } from "react";
import { useRouter } from "next/navigation";
import { jwtDecode } from "jwt-decode";
import { clearTokenCookies, getCookie } from "@/utils/cookieUtils";
const useAuthorized = () => {
const router = useRouter();
const token = typeof document !== "undefined" ? getCookie("token") : null;
// Redirect after mount if missing/invalid token
useEffect(() => {
if (!token) {
router.replace("/sso/key/generate");
}
}, [token, router]);
// Decode safely
const decoded = useMemo(() => {
if (!token) return null;
try {
return jwtDecode(token) as Record<string, any>;
} catch {
// Bad token in cookie — clear and bounce
clearTokenCookies();
router.replace("/sso/key/generate");
return null;
}
}, [token, router]);
return {
token: token,
accessToken: decoded?.key ?? null,
userId: decoded?.user_id ?? null,
userEmail: decoded?.user_email ?? null,
userRole: decoded?.user_role ?? null,
premiumUser: decoded?.premium_user ?? null,
disabledPersonalKeyCreation: decoded?.disabled_non_admin_personal_key_creation ?? null,
showSSOBanner: decoded?.login_method === "username_password" ?? false,
};
};
export default useAuthorized;
@@ -0,0 +1,20 @@
import { useEffect, useState } from "react";
import { Team } from "@/components/key_team_helpers/key_list";
import useAuthorized from "@/app/(dashboard)/hooks/useAuthorized";
import { fetchTeams } from "@/app/(dashboard)/networking";
const useTeams = () => {
const [teams, setTeams] = useState<Team[] | null>([]);
const { accessToken, userId: userID, userRole } = useAuthorized();
useEffect(() => {
(async () => {
const fetched = await fetchTeams(accessToken, userID, userRole, null);
setTeams(fetched);
})();
}, [accessToken, userID, userRole]);
return { teams, setTeams };
};
export default useTeams;
@@ -0,0 +1,73 @@
"use client";
import React, { useEffect, useState } from "react";
import Navbar from "@/components/navbar";
import { ThemeProvider } from "@/contexts/ThemeContext";
import Sidebar2 from "@/app/(dashboard)/components/Sidebar2";
import useAuthorized from "@/app/(dashboard)/hooks/useAuthorized";
import { useRouter, useSearchParams } from "next/navigation";
/** ---- BASE URL HELPERS ---- */
function normalizeBasePrefix(raw: string | undefined | null): string {
const trimmed = (raw ?? "").trim();
if (!trimmed) return "";
const core = trimmed.replace(/^\/+/, "").replace(/\/+$/, "");
return core ? `/${core}/` : "/";
}
const BASE_PREFIX = normalizeBasePrefix(process.env.NEXT_PUBLIC_BASE_URL);
function withBase(path: string): string {
const body = path.startsWith("/") ? path.slice(1) : path;
const combined = `${BASE_PREFIX}${body}`;
return combined.startsWith("/") ? combined : `/${combined}`;
}
/** -------------------------------- */
export default function Layout({ children }: { children: React.ReactNode }) {
const router = useRouter();
const searchParams = useSearchParams();
const { accessToken, userRole } = useAuthorized();
const [sidebarCollapsed, setSidebarCollapsed] = React.useState(false);
const [page, setPage] = useState(() => {
return searchParams.get("page") || "api-keys";
});
const updatePage = (newPage: string) => {
const newSearchParams = new URLSearchParams(searchParams);
newSearchParams.set("page", newPage);
router.push(withBase(`/?${newSearchParams.toString()}`)); // always under BASE
setPage(newPage);
};
useEffect(() => {
setPage(searchParams.get("page") || "api-keys");
}, [searchParams]);
const toggleSidebar = () => setSidebarCollapsed((v) => !v);
return (
<ThemeProvider accessToken={""}>
<div className="flex flex-col min-h-screen">
<Navbar
isPublicPage={false}
sidebarCollapsed={sidebarCollapsed}
onToggleSidebar={toggleSidebar}
userID={null}
userEmail={null}
userRole={null}
premiumUser={false}
proxySettings={undefined}
setProxySettings={function (value: any): void {
throw new Error("Function not implemented.");
}}
accessToken={null}
/>
<div className="flex flex-1 overflow-auto">
<div className="mt-2">
<Sidebar2 defaultSelectedKey={page} accessToken={accessToken} userRole={userRole} />
</div>
<main className="flex-1">{children}</main>
</div>
</div>
</ThemeProvider>
);
}
@@ -0,0 +1,28 @@
"use client";
import SpendLogsTable from "@/components/view_logs";
import useAuthorized from "@/app/(dashboard)/hooks/useAuthorized";
import useTeams from "@/app/(dashboard)/hooks/useTeams";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
const LogsPage = () => {
const { accessToken, token, userRole, userId, premiumUser } = useAuthorized();
const { teams } = useTeams();
const queryClient = new QueryClient();
return (
<QueryClientProvider client={queryClient}>
<SpendLogsTable
accessToken={accessToken}
token={token}
userRole={userRole}
userID={userId}
allTeams={teams || []}
premiumUser={premiumUser}
/>
</QueryClientProvider>
);
};
export default LogsPage;
@@ -0,0 +1,12 @@
"use client";
import ModelHubTable from "@/components/model_hub_table";
import useAuthorized from "@/app/(dashboard)/hooks/useAuthorized";
const ModelHubPage = () => {
const { accessToken, premiumUser, userRole } = useAuthorized();
return <ModelHubTable accessToken={accessToken} publicPage={false} premiumUser={premiumUser} userRole={userRole} />;
};
export default ModelHubPage;
@@ -0,0 +1,29 @@
"use client";
import ModelDashboard from "@/components/templates/model_dashboard";
import useAuthorized from "@/app/(dashboard)/hooks/useAuthorized";
import useTeams from "@/app/(dashboard)/hooks/useTeams";
import { useState } from "react";
const ModelsAndEndpointsPage = () => {
const { token, accessToken, userRole, userId, premiumUser } = useAuthorized();
const [keys, setKeys] = useState<null | any[]>([]);
const { teams } = useTeams();
return (
<ModelDashboard
accessToken={accessToken}
token={token}
userRole={userRole}
userID={userId}
modelData={{ data: [] }}
keys={keys}
setModelData={() => {}}
premiumUser={premiumUser}
teams={teams}
/>
);
};
export default ModelsAndEndpointsPage;
@@ -0,0 +1,17 @@
import { Organization, teamListCall } from "@/components/networking";
export const fetchTeams = async (
accessToken: string,
userID: string | null,
userRole: string | null,
currentOrg: Organization | null,
) => {
let givenTeams;
if (userRole != "Admin" && userRole != "Admin Viewer") {
givenTeams = await teamListCall(accessToken, currentOrg?.organization_id || null, userID);
} else {
givenTeams = await teamListCall(accessToken, currentOrg?.organization_id || null);
}
return givenTeams;
};
@@ -0,0 +1,34 @@
"use client";
import Organizations, { fetchOrganizations } from "@/components/organizations";
import useAuthorized from "@/app/(dashboard)/hooks/useAuthorized";
import { useEffect, useState } from "react";
import { Organization } from "@/components/networking";
import { fetchUserModels } from "@/components/organisms/create_key_button";
const OrganizationsPage = () => {
const { userId: userID, accessToken, userRole, premiumUser } = useAuthorized();
const [organizations, setOrganizations] = useState<Organization[]>([]);
const [userModels, setUserModels] = useState<string[]>([]);
useEffect(() => {
fetchOrganizations(accessToken, setOrganizations).then(() => {});
}, [accessToken]);
useEffect(() => {
fetchUserModels(userID, userRole, accessToken, setUserModels).then(() => {});
}, [userID, userRole, accessToken]);
return (
<Organizations
organizations={organizations}
userRole={userRole}
userModels={userModels}
accessToken={accessToken}
setOrganizations={setOrganizations}
premiumUser={premiumUser}
/>
);
};
export default OrganizationsPage;
@@ -0,0 +1,29 @@
"use client";
import AdminPanel from "@/components/admins";
import useAuthorized from "@/app/(dashboard)/hooks/useAuthorized";
import { useState } from "react";
import { Team } from "@/components/key_team_helpers/key_list";
import useTeams from "@/app/(dashboard)/hooks/useTeams";
const AdminSettings = () => {
const { teams, setTeams } = useTeams();
const [searchParams, setSearchParams] = useState<URLSearchParams>(() =>
typeof window === "undefined" ? new URLSearchParams() : new URLSearchParams(window.location.search),
);
const { accessToken, userId, premiumUser, showSSOBanner } = useAuthorized();
return (
<AdminPanel
searchParams={searchParams}
accessToken={accessToken}
userID={userId}
setTeams={setTeams}
showSSOBanner={showSSOBanner}
premiumUser={premiumUser}
/>
);
};
export default AdminSettings;
@@ -0,0 +1,12 @@
"use client";
import Settings from "@/components/settings";
import useAuthorized from "@/app/(dashboard)/hooks/useAuthorized";
const LoggingAndAlertsPage = () => {
const { accessToken, userRole, userId, premiumUser } = useAuthorized();
return <Settings accessToken={accessToken} userRole={userRole} userID={userId} premiumUser={premiumUser} />;
};
export default LoggingAndAlertsPage;
@@ -0,0 +1,12 @@
"use client";
import GeneralSettings from "@/components/general_settings";
import useAuthorized from "@/app/(dashboard)/hooks/useAuthorized";
const RouterSettingsPage = () => {
const { accessToken, userRole, userId } = useAuthorized();
return <GeneralSettings accessToken={accessToken} userRole={userRole} userID={userId} modelData={{}} />;
};
export default RouterSettingsPage;
@@ -0,0 +1,12 @@
"use client";
import UIThemeSettings from "@/components/ui_theme_settings";
import useAuthorized from "@/app/(dashboard)/hooks/useAuthorized";
const UIThemePage = () => {
const { userId, userRole, accessToken } = useAuthorized();
return <UIThemeSettings userID={userId} userRole={userRole} accessToken={accessToken} />;
};
export default UIThemePage;
@@ -0,0 +1,35 @@
"use client";
import Teams from "@/components/teams";
import useAuthorized from "@/app/(dashboard)/hooks/useAuthorized";
import useTeams from "@/app/(dashboard)/hooks/useTeams";
import { useEffect, useState } from "react";
import { Organization } from "@/components/networking";
import { fetchOrganizations } from "@/components/organizations";
const TeamsPage = () => {
const { accessToken, userId, userRole } = useAuthorized();
const { teams, setTeams } = useTeams();
const [searchParams, setSearchParams] = useState<URLSearchParams>(() =>
typeof window === "undefined" ? new URLSearchParams() : new URLSearchParams(window.location.search),
);
const [organizations, setOrganizations] = useState<Organization[]>([]);
useEffect(() => {
fetchOrganizations(accessToken, setOrganizations).then(() => {});
}, [accessToken]);
return (
<Teams
teams={teams}
searchParams={searchParams}
accessToken={accessToken}
setTeams={setTeams}
userID={userId}
userRole={userRole}
organizations={organizations}
/>
);
};
export default TeamsPage;
@@ -0,0 +1,20 @@
"use client";
import ChatUI from "@/components/chat_ui/ChatUI";
import useAuthorized from "@/app/(dashboard)/hooks/useAuthorized";
const TestKeyPage = () => {
const { token, accessToken, userRole, userId, disabledPersonalKeyCreation } = useAuthorized();
return (
<ChatUI
accessToken={accessToken}
token={token}
userRole={userRole}
userID={userId}
disabledPersonalKeyCreation={disabledPersonalKeyCreation}
/>
);
};
export default TestKeyPage;
@@ -0,0 +1,19 @@
"use client";
import { MCPServers } from "@/components/mcp_tools";
import useAuthorized from "@/app/(dashboard)/hooks/useAuthorized";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
const MCPServersPage = () => {
const { accessToken, userRole, userId } = useAuthorized();
const queryClient = new QueryClient();
return (
<QueryClientProvider client={queryClient}>
<MCPServers accessToken={accessToken} userRole={userRole} userID={userId} />
</QueryClientProvider>
);
};
export default MCPServersPage;
@@ -0,0 +1,12 @@
"use client";
import VectorStoreManagement from "@/components/vector_store_management";
import useAuthorized from "@/app/(dashboard)/hooks/useAuthorized";
const VectorStoresPage = () => {
const { accessToken, userId, userRole } = useAuthorized();
return <VectorStoreManagement accessToken={accessToken} userID={userId} userRole={userRole} />;
};
export default VectorStoresPage;
@@ -0,0 +1,22 @@
"use client";
import NewUsagePage from "@/components/new_usage";
import useAuthorized from "@/app/(dashboard)/hooks/useAuthorized";
import useTeams from "@/app/(dashboard)/hooks/useTeams";
const UsagePage = () => {
const { accessToken, userRole, userId, premiumUser } = useAuthorized();
const { teams } = useTeams();
return (
<NewUsagePage
accessToken={accessToken}
userRole={userRole}
userID={userId}
teams={teams ?? []}
premiumUser={premiumUser}
/>
);
};
export default UsagePage;
@@ -0,0 +1,31 @@
"use client";
import ViewUserDashboard from "@/components/view_users";
import useAuthorized from "@/app/(dashboard)/hooks/useAuthorized";
import useTeams from "@/app/(dashboard)/hooks/useTeams";
import { useState } from "react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
const UsersPage = () => {
const { accessToken, userRole, userId, token } = useAuthorized();
const [keys, setKeys] = useState<null | any[]>([]);
const { teams } = useTeams();
const queryClient = new QueryClient();
return (
<QueryClientProvider client={queryClient}>
<ViewUserDashboard
accessToken={accessToken}
token={token}
keys={keys}
userRole={userRole}
userID={userId}
teams={teams as any}
setKeys={setKeys}
/>
</QueryClientProvider>
);
};
export default UsersPage;
@@ -0,0 +1,52 @@
"use client";
import { useState } from "react";
import useKeyList, { KeyResponse } from "@/components/key_team_helpers/key_list";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import useAuthorized from "@/app/(dashboard)/hooks/useAuthorized";
import UserDashboard from "@/components/user_dashboard";
import useTeams from "@/app/(dashboard)/hooks/useTeams";
import { Organization } from "@/components/networking";
const VirtualKeysPage = () => {
const { accessToken, userRole, userId, premiumUser, userEmail } = useAuthorized();
const { teams, setTeams } = useTeams();
const [createClicked, setCreateClicked] = useState<boolean>(false);
const [organizations, setOrganizations] = useState<Organization[]>([]);
const queryClient = new QueryClient();
const { keys, isLoading, error, pagination, refresh, setKeys } = useKeyList({
selectedKeyAlias: null,
currentOrg: null,
accessToken: accessToken || "",
createClicked,
});
const addKey = (data: any) => {
setKeys((prevData) => (prevData ? [...prevData, data] : [data]));
setCreateClicked(() => !createClicked);
};
return (
<QueryClientProvider client={queryClient}>
<UserDashboard
userID={userId}
userRole={userRole}
userEmail={userEmail}
teams={teams}
keys={keys}
setUserRole={() => {}}
setUserEmail={() => {}}
setTeams={setTeams}
setKeys={setKeys}
premiumUser={premiumUser}
organizations={organizations}
addKey={addKey}
createClicked={createClicked}
/>
</QueryClientProvider>
);
};
export default VirtualKeysPage;
+7 -1
View File
@@ -1,6 +1,7 @@
import type { Metadata } from "next";
import { Inter } from "next/font/google";
import "./globals.css";
import { FeatureFlagsProvider } from "@/hooks/useFeatureFlags";
const inter = Inter({ subsets: ["latin"] });
@@ -17,7 +18,12 @@ export default function RootLayout({
}>) {
return (
<html lang="en">
<body className={inter.className}>{children}</body>
<body className={inter.className}>
<FeatureFlagsProvider>
{children}
</FeatureFlagsProvider>
</body>
</html>
);
}
+80 -18
View File
@@ -23,7 +23,6 @@ import ModelHubTable from "@/components/model_hub_table";
import NewUsagePage from "@/components/new_usage";
import APIRef from "@/components/api_ref";
import ChatUI from "@/components/chat_ui/ChatUI";
import Sidebar from "@/components/leftnav";
import Usage from "@/components/usage";
import CacheDashboard from "@/components/cache_dashboard";
import { getUiConfig, proxyBaseUrl, setGlobalLitellmHeaderName } from "@/components/networking";
@@ -39,10 +38,37 @@ import VectorStoreManagement from "@/components/vector_store_management";
import UIThemeSettings from "@/components/ui_theme_settings";
import { UiLoadingSpinner } from "@/components/ui/ui-loading-spinner";
import { cx } from "@/lib/cva.config";
import useFeatureFlags from "@/hooks/useFeatureFlags";
import SidebarProvider from "@/app/(dashboard)/components/SidebarProvider";
function getCookie(name: string) {
const cookieValue = document.cookie.split("; ").find((row) => row.startsWith(name + "="));
return cookieValue ? cookieValue.split("=")[1] : null;
// Safer cookie read + decoding; handles '=' inside values
const match = document.cookie.split("; ").find((row) => row.startsWith(name + "="));
if (!match) return null;
const value = match.slice(name.length + 1);
try {
return decodeURIComponent(value);
} catch {
return value;
}
}
function deleteCookie(name: string, path = "/") {
// Best-effort client-side clear (works for non-HttpOnly cookies without Domain)
document.cookie = `${name}=; Max-Age=0; Path=${path}`;
}
function isJwtExpired(token: string): boolean {
try {
const decoded: any = jwtDecode(token);
if (decoded && typeof decoded.exp === "number") {
return decoded.exp * 1000 <= Date.now();
}
return false;
} catch {
// If we can't decode, treat as invalid/expired
return true;
}
}
function formatUserRole(userRole: string) {
@@ -115,6 +141,7 @@ export default function CreateKeyPage() {
const [createClicked, setCreateClicked] = useState<boolean>(false);
const [authLoading, setAuthLoading] = useState(true);
const [userID, setUserID] = useState<string | null>(null);
const { refactoredUIFlag } = useFeatureFlags();
const invitation_id = searchParams.get("invitation_id");
@@ -149,17 +176,42 @@ export default function CreateKeyPage() {
const redirectToLogin = authLoading === false && token === null && invitation_id === null;
useEffect(() => {
const token = getCookie("token");
getUiConfig().then((data) => {
// get the information for constructing the proxy base url, and then set the token and auth loading
setToken(token);
setAuthLoading(false);
});
let cancelled = false;
(async () => {
try {
await getUiConfig(); // ensures proxyBaseUrl etc. are ready
} catch {
// proceed regardless; we still need to decide auth state
}
if (cancelled) return;
const raw = getCookie("token");
const valid = raw && !isJwtExpired(raw) ? raw : null;
// If token exists but is invalid/expired, clear it so downstream code
// doesn't keep trying to use it and cause redirect spasms.
if (raw && !valid) {
deleteCookie("token", "/");
}
if (!cancelled) {
setToken(valid);
setAuthLoading(false);
}
})();
return () => {
cancelled = true;
};
}, []);
useEffect(() => {
if (redirectToLogin) {
window.location.href = (proxyBaseUrl || "") + "/sso/key/generate";
// Replace instead of assigning to avoid back-button loops
const dest = (proxyBaseUrl || "") + "/sso/key/generate";
window.location.replace(dest);
}
}, [redirectToLogin]);
@@ -168,7 +220,23 @@ export default function CreateKeyPage() {
return;
}
const decoded = jwtDecode(token) as { [key: string]: any };
// Defensive: re-check expiry in case cookie changed after mount
if (isJwtExpired(token)) {
deleteCookie("token", "/");
setToken(null);
return;
}
let decoded: any = null;
try {
decoded = jwtDecode(token);
} catch {
// Malformed token → treat as unauthenticated
deleteCookie("token", "/");
setToken(null);
return;
}
if (decoded) {
// set accessToken
setAccessToken(decoded.key);
@@ -258,13 +326,7 @@ export default function CreateKeyPage() {
/>
<div className="flex flex-1 overflow-auto">
<div className="mt-2">
<Sidebar
accessToken={accessToken}
setPage={updatePage}
userRole={userRole}
defaultSelectedKey={page}
collapsed={sidebarCollapsed}
/>
<SidebarProvider setPage={updatePage} defaultSelectedKey={page} sidebarCollapsed={sidebarCollapsed} />
</div>
{page == "api-keys" ? (
@@ -524,9 +524,7 @@ const AdminPanel: React.FC<AdminPanelProps> = ({
<Button
style={{ width: "150px" }}
onClick={() =>
premiumUser === true
? setIsAddSSOModalVisible(true)
: NotificationsManager.fromBackend("Only premium users can add SSO")
setIsAddSSOModalVisible(true)
}
>
{ssoConfigured ? "Edit SSO Settings" : "Add SSO"}
+18 -2
View File
@@ -1,7 +1,7 @@
import Link from "next/link";
import React, { useState, useEffect } from "react";
import type { MenuProps } from "antd";
import { Dropdown, Tooltip } from "antd";
import { Dropdown, Tooltip, Switch } from "antd";
import { getProxyBaseUrl, Organization } from "@/components/networking";
import { defaultOrg } from "@/components/common_components/default_org";
import {
@@ -19,6 +19,7 @@ import { clearTokenCookies } from "@/utils/cookieUtils";
import { fetchProxySettings } from "@/utils/proxyUtils";
import { useTheme } from "@/contexts/ThemeContext";
import { clearMCPAuthTokens } from "./mcp_tools/mcp_auth_storage";
import useFeatureFlags from "@/hooks/useFeatureFlags";
interface NavbarProps {
userID: string | null;
@@ -43,11 +44,12 @@ const Navbar: React.FC<NavbarProps> = ({
accessToken,
isPublicPage = false,
sidebarCollapsed = false,
onToggleSidebar,
onToggleSidebar
}) => {
const baseUrl = getProxyBaseUrl();
const [logoutUrl, setLogoutUrl] = useState("");
const { logoUrl } = useTheme();
const { refactoredUIFlag, setRefactoredUIFlag } = useFeatureFlags();
// Simple logo URL: use custom logo if available, otherwise default
const imageUrl = logoUrl || `${baseUrl}/get_image`;
@@ -79,6 +81,8 @@ const Navbar: React.FC<NavbarProps> = ({
const userItems: MenuProps["items"] = [
{
key: "user-info",
// Prevent dropdown from closing when interacting with the toggle
onClick: (info) => info.domEvent?.stopPropagation(),
label: (
<div className="px-3 py-3 border-b border-gray-100">
<div className="flex items-center justify-between mb-3">
@@ -115,6 +119,18 @@ const Navbar: React.FC<NavbarProps> = ({
{userEmail || "Unknown"}
</span>
</div>
{/* NEW: Feature flag label + toggle below the email field */}
<div className="flex items-center text-sm pt-2 mt-2 border-t border-gray-100">
<span className="text-gray-500 text-xs">Refactored UI</span>
<Switch
className="ml-auto"
size="small"
checked={refactoredUIFlag}
onChange={(checked) => setRefactoredUIFlag(checked)}
aria-label="Toggle refactored UI feature flag"
/>
</div>
</div>
</div>
),
@@ -34,18 +34,34 @@ if (isLocal != true) {
console.log = function () {};
}
const getWindowLocation = () => {
if (typeof window === "undefined") {
return null;
}
return window.location;
};
const updateProxyBaseUrl = (serverRootPath: string, receivedProxyBaseUrl: string | null = null) => {
/**
* Special function for updating the proxy base url. Should only be called by getUiConfig.
*/
const defaultProxyBaseUrl = isLocal ? "http://localhost:4000" : window.location.origin;
let initialProxyBaseUrl = receivedProxyBaseUrl || defaultProxyBaseUrl;
const browserLocation = getWindowLocation();
const resolvedDefaultProxyBaseUrl = isLocal ? "http://localhost:4000" : browserLocation?.origin ?? null;
let initialProxyBaseUrl = receivedProxyBaseUrl || resolvedDefaultProxyBaseUrl;
console.log("proxyBaseUrl:", proxyBaseUrl);
console.log("serverRootPath:", serverRootPath);
if (!initialProxyBaseUrl) {
proxyBaseUrl = proxyBaseUrl ?? null;
console.log("Updated proxyBaseUrl:", proxyBaseUrl);
return;
}
if (serverRootPath.length > 0 && !initialProxyBaseUrl.endsWith(serverRootPath) && serverRootPath != "/") {
initialProxyBaseUrl += serverRootPath;
proxyBaseUrl = initialProxyBaseUrl;
}
proxyBaseUrl = initialProxyBaseUrl;
console.log("Updated proxyBaseUrl:", proxyBaseUrl);
};
@@ -54,7 +70,11 @@ const updateServerRootPath = (receivedServerRootPath: string) => {
};
export const getProxyBaseUrl = (): string => {
return proxyBaseUrl ? proxyBaseUrl : window.location.origin;
if (proxyBaseUrl) {
return proxyBaseUrl;
}
const browserLocation = getWindowLocation();
return browserLocation?.origin ?? "";
};
const HTTP_REQUEST = {
@@ -159,7 +179,10 @@ const handleError = async (errorData: string) => {
NotificationsManager.info("UI Session Expired. Logging out.");
lastErrorTime = currentTime;
clearTokenCookies();
window.location.href = window.location.pathname;
const browserLocation = getWindowLocation();
if (browserLocation) {
window.location.href = browserLocation.pathname;
}
}
lastErrorTime = currentTime;
} else {
@@ -132,6 +132,13 @@ export const fetchUserModels = async (
}
};
/**
*
* @deprecated
* This component is being DEPRECATED in favor of src/app/(dashboard)/virtual-keys/components/CreateKey.tsx
* Please contribute to the new refactor.
*
*/
const CreateKey: React.FC<CreateKeyProps> = ({
userID,
team,
@@ -47,6 +47,13 @@ interface KeyInfoViewProps {
backButtonText?: string;
}
/**
*
* @deprecated
* This component is being DEPRECATED in favor of src/app/(dashboard)/virtual-keys/components/KeyInfoView.tsx
* Please contribute to the new refactor.
*
*/
export default function KeyInfoView({
keyId,
onClose,
@@ -90,7 +90,7 @@ interface ViewKeyTableProps {
selectedTeam: Team | null;
setSelectedTeam: React.Dispatch<React.SetStateAction<any | null>>;
data: KeyResponse[] | null;
setData: React.Dispatch<React.SetStateAction<any[] | null>>;
setData: (keys: KeyResponse[]) => void;
teams: Team[] | null;
premiumUser: boolean;
currentOrg: Organization | null;
@@ -20,11 +20,12 @@ import ViewUserTeam from "./view_user_team";
import DashboardTeam from "./dashboard_default_team";
import Onboarding from "../app/onboarding/page";
import { useSearchParams, useRouter } from "next/navigation";
import { Team } from "./key_team_helpers/key_list";
import { KeyResponse, Team } from "./key_team_helpers/key_list";
import { jwtDecode } from "jwt-decode";
import { Typography } from "antd";
import { clearTokenCookies } from "@/utils/cookieUtils";
import { clearMCPAuthTokens } from "./mcp_tools/mcp_auth_storage";
import { Setter } from "@/types";
export interface ProxySettings {
PROXY_BASE_URL: string | null;
@@ -56,7 +57,7 @@ interface UserDashboardProps {
setUserRole: React.Dispatch<React.SetStateAction<string>>;
setUserEmail: React.Dispatch<React.SetStateAction<string | null>>;
setTeams: React.Dispatch<React.SetStateAction<Team[] | null>>;
setKeys: React.Dispatch<React.SetStateAction<Object[] | null>>;
setKeys: (keys: KeyResponse[]) => void;
premiumUser: boolean;
organizations: Organization[] | null;
addKey: (data: any) => void;
@@ -30,6 +30,8 @@ import { updateExistingKeys } from "@/utils/dataUtils";
import { useDebouncedState } from "@tanstack/react-pacer/debouncer";
import { isAdminRole } from "@/utils/roles";
import NotificationsManager from "./molecules/notifications_manager";
import { Setter } from "@/types";
import { KeyResponse } from "@/components/key_team_helpers/key_list";
interface ViewUserDashboardProps {
accessToken: string | null;
@@ -0,0 +1,112 @@
"use client";
const getBasePath = () => {
const raw = process.env.NEXT_PUBLIC_BASE_URL ?? "";
const trimmed = raw.replace(/^\/+|\/+$/g, ""); // strip leading/trailing slashes
return trimmed ? `/${trimmed}/` : "/"; // ensure trailing slash
};
import React, { createContext, useContext, useEffect, useState } from "react";
import { useRouter } from "next/navigation"; // ⟵ add this
type Flags = {
refactoredUIFlag: boolean;
setRefactoredUIFlag: (v: boolean) => void;
};
const STORAGE_KEY = "feature.refactoredUIFlag";
const FeatureFlagsCtx = createContext<Flags | null>(null);
/** Safely read the flag from localStorage. If anything goes wrong, reset to false. */
function readFlagSafely(): boolean {
try {
const raw = localStorage.getItem(STORAGE_KEY);
if (raw === null) {
localStorage.setItem(STORAGE_KEY, "false");
return false;
}
const v = raw.trim().toLowerCase();
if (v === "true" || v === "1") return true;
if (v === "false" || v === "0") return false;
// Last chance: try JSON.parse in case something odd was stored.
const parsed = JSON.parse(raw);
if (typeof parsed === "boolean") return parsed;
// Malformed → reset to false
localStorage.setItem(STORAGE_KEY, "false");
return false;
} catch {
// If even accessing localStorage throws, best effort reset then default to false
try {
localStorage.setItem(STORAGE_KEY, "false");
} catch {}
return false;
}
}
function writeFlagSafely(v: boolean) {
try {
localStorage.setItem(STORAGE_KEY, String(v));
} catch {
// Ignore write errors; state will still reflect the intended value.
}
}
export const FeatureFlagsProvider = ({ children }: { children: React.ReactNode }) => {
const router = useRouter(); // ⟵ add this
// Lazy init reads from localStorage only on the client
const [refactoredUIFlag, setRefactoredUIFlagState] = useState<boolean>(() => readFlagSafely());
const setRefactoredUIFlag = (v: boolean) => {
setRefactoredUIFlagState(v);
writeFlagSafely(v);
};
// Keep this flag in sync across tabs/windows.
useEffect(() => {
const onStorage = (e: StorageEvent) => {
if (e.key === STORAGE_KEY && e.newValue != null) {
const next = e.newValue.trim().toLowerCase();
setRefactoredUIFlagState(next === "true" || next === "1");
}
// If the key was cleared elsewhere, self-heal to false.
if (e.key === STORAGE_KEY && e.newValue === null) {
writeFlagSafely(false);
setRefactoredUIFlagState(false);
}
};
window.addEventListener("storage", onStorage);
return () => window.removeEventListener("storage", onStorage);
}, []);
// Redirect to base path the moment the flag is OFF.
useEffect(() => {
if (refactoredUIFlag) return; // only act when turned off
const base = getBasePath();
const normalize = (p: string) => (p.endsWith("/") ? p : p + "/");
const current = normalize(window.location.pathname);
// Avoid a redirect loop if we're already at the base path.
if (current !== base) {
// Replace so the "off" redirect doesn't pollute history.
router.replace(base);
}
}, [refactoredUIFlag, router]);
return (
<FeatureFlagsCtx.Provider value={{ refactoredUIFlag, setRefactoredUIFlag }}>{children}</FeatureFlagsCtx.Provider>
);
};
const useFeatureFlags = () => {
const ctx = useContext(FeatureFlagsCtx);
if (!ctx) throw new Error("useFeatureFlags must be used within FeatureFlagsProvider");
return ctx;
};
export default useFeatureFlags;
@@ -6,6 +6,10 @@
* Clears the token cookie from both root and /ui paths
*/
export function clearTokenCookies() {
if (typeof window === "undefined" || typeof document === "undefined") {
return;
}
// Get the current domain
const domain = window.location.hostname;
@@ -37,6 +41,7 @@ export function clearTokenCookies() {
* @returns The cookie value or null if not found
*/
export function getCookie(name: string) {
if (typeof document === "undefined") return null;
const cookieValue = document.cookie.split("; ").find((row) => row.startsWith(name + "="));
return cookieValue ? cookieValue.split("=")[1] : null;
}
+1 -1
View File
@@ -5,7 +5,7 @@ export const all_admin_roles = [...old_admin_roles, ...v2_admin_role_names];
export const internalUserRoles = ["Internal User", "Internal Viewer"];
export const rolesAllowedToSeeUsage = ["Admin", "Admin Viewer", "Internal User", "Internal Viewer"];
export const rolesWithWriteAccess = ["Internal User", "Admin"];
export const rolesWithWriteAccess = ["Internal User", "Admin", "proxy_admin"];
// Helper function to check if a role is in all_admin_roles
export const isAdminRole = (role: string): boolean => {
@@ -0,0 +1,195 @@
import React from "react";
import { render, screen, waitFor } from "@testing-library/react";
import { vi, describe, it, beforeEach, afterEach, expect } from "vitest";
/** ----------------------------
* Hoisted helpers for mocks (required by Vitest)
* --------------------------- */
const { stub, jwtDecodeMock } = vi.hoisted(() => {
const React = require("react");
const stub = (name: string) => () => React.createElement("div", { "data-testid": name });
return {
stub,
jwtDecodeMock: vi.fn(),
};
});
/** ----------------------------
* Mocks
* --------------------------- */
// next/navigation: just return empty URLSearchParams (no invitation/page)
vi.mock("next/navigation", () => ({
useSearchParams: () => new URLSearchParams(""),
}));
// Networking layer
vi.mock("@/components/networking", () => {
return {
// Called on mount; we don't care about its contents, only that it resolves
getUiConfig: vi.fn().mockResolvedValue({}),
// Used to build the redirect URL
proxyBaseUrl: "https://example.com",
// Called when decoding a valid token
setGlobalLitellmHeaderName: vi.fn(),
Organization: {},
};
});
// jwt-decode: well swap implementation per test via mockImplementation
vi.mock("jwt-decode", () => ({
jwtDecode: (token: string) => jwtDecodeMock(token),
}));
// Super-light stubs for all heavy components so rendering doesn't explode
vi.mock("@/components/navbar", () => ({ default: stub("navbar") }));
vi.mock("@/components/user_dashboard", () => ({ default: stub("user-dashboard") }));
vi.mock("@/components/templates/model_dashboard", () => ({ default: stub("model-dashboard") }));
vi.mock("@/components/view_users", () => ({ default: stub("view-users") }));
vi.mock("@/components/teams", () => ({ default: stub("teams") }));
vi.mock("@/components/organizations", () => ({
default: stub("organizations"),
fetchOrganizations: vi.fn(), // consumed in effects
}));
vi.mock("@/components/admins", () => ({ default: stub("admin-panel") }));
vi.mock("@/components/settings", () => ({ default: stub("settings") }));
vi.mock("@/components/general_settings", () => ({ default: stub("general-settings") }));
vi.mock("@/components/pass_through_settings", () => ({ default: stub("pass-through-settings") }));
vi.mock("@/components/budgets/budget_panel", () => ({ default: stub("budget-panel") }));
vi.mock("@/components/view_logs", () => ({ default: stub("spend-logs") }));
vi.mock("@/components/model_hub_table", () => ({ default: stub("model-hub-table") }));
vi.mock("@/components/new_usage", () => ({ default: stub("new-usage") }));
vi.mock("@/components/api_ref", () => ({ default: stub("api-ref") }));
vi.mock("@/components/chat_ui/ChatUI", () => ({ default: stub("chat-ui") }));
vi.mock("@/components/leftnav", () => ({ default: stub("sidebar") }));
vi.mock("@/components/usage", () => ({ default: stub("usage") }));
vi.mock("@/components/cache_dashboard", () => ({ default: stub("cache-dashboard") }));
vi.mock("@/components/guardrails", () => ({ default: stub("guardrails") }));
vi.mock("@/components/prompts", () => ({ default: stub("prompts") }));
vi.mock("@/components/transform_request", () => ({ default: stub("transform-request") }));
vi.mock("@/components/mcp_tools", () => ({ MCPServers: stub("mcp-servers") }));
vi.mock("@/components/tag_management", () => ({ default: stub("tag-management") }));
vi.mock("@/components/vector_store_management", () => ({ default: stub("vector-stores") }));
vi.mock("@/components/ui_theme_settings", () => ({ default: stub("ui-theme-settings") }));
vi.mock("@/components/organisms/create_key_button", () => ({ fetchUserModels: vi.fn() }));
vi.mock("@/components/common_components/fetch_teams", () => ({ fetchTeams: vi.fn() }));
vi.mock("@/components/ui/ui-loading-spinner", () => ({
UiLoadingSpinner: stub("spinner"),
}));
vi.mock("@/contexts/ThemeContext", () => {
const React = require("react");
return {
ThemeProvider: ({ children }: any) => React.createElement(React.Fragment, null, children),
};
});
vi.mock("@/lib/cva.config", () => ({
cx: (...args: string[]) => args.join(" "),
}));
import CreateKeyPage from "@/app/page";
/** ----------------------------
* Helpers
* --------------------------- */
function setCookie(raw: string) {
// JSDOM allows simple string assignment to document.cookie
document.cookie = raw;
}
function clearAllCookies() {
// JSDOM doesn't give an API to clear; overwrite with empty string
// plus ensure we wipe known names used by this app.
document.cookie = "token=; Max-Age=0; Path=/";
}
const originalLocation = window.location;
beforeEach(() => {
// Fresh module state & DOM
vi.clearAllMocks();
clearAllCookies();
// Make location.replace spy-able to validate redirect
delete (window as any).location;
// minimal location object with replace and assign stubs
(window as any).location = {
...originalLocation,
href: "http://localhost/",
assign: vi.fn(),
replace: vi.fn(),
};
});
afterEach(() => {
// Restore location to avoid leaking across test envs
delete (window as any).location;
(window as any).location = originalLocation;
});
/** ----------------------------
* Tests
* --------------------------- */
describe("CreateKeyPage auth behavior", () => {
it("redirects to SSO when cookie token is expired and clears it (no spasms)", async () => {
// Arrange: expired token in cookie
setCookie("token=expiredtoken");
// jwtDecode returns past exp → expired
jwtDecodeMock.mockImplementation((tok: string) => {
expect(tok).toBe("expiredtoken");
return { exp: Math.floor(Date.now() / 1000) - 60 }; // expired 60s ago
});
// Spy on cookie writes to ensure we clear with Max-Age=0
const cookieSetSpy = vi.spyOn(document, "cookie", "set");
// Act
render(<CreateKeyPage />);
// Assert: we eventually redirect to SSO login (single replace, not assign/href)
await waitFor(() => {
expect(window.location.replace).toHaveBeenCalledWith("https://example.com/sso/key/generate");
});
// And we attempted to clear the cookie (defensive deletion)
const wroteDeletion = cookieSetSpy.mock.calls.some(
(args) => typeof args[0] === "string" && args[0].includes("Max-Age=0") && args[0].startsWith("token="),
);
expect(wroteDeletion).toBe(true);
});
it("does NOT redirect when token is valid and renders the app chrome", async () => {
// Arrange: valid token in cookie
setCookie("token=validtoken");
// jwtDecode returns future exp and expected shape
jwtDecodeMock.mockImplementation((tok: string) => {
expect(tok).toBe("validtoken");
return {
exp: Math.floor(Date.now() / 1000) + 60 * 60, // 1h in the future
key: "accessKey-123",
user_role: "app_user",
user_email: "user@example.com",
login_method: "username_password",
premium_user: false,
auth_header_name: "x-litellm-auth",
user_id: "u_123",
};
});
// Act
render(<CreateKeyPage />);
// Assert: no redirect
await waitFor(() => {
expect(window.location.replace).not.toHaveBeenCalled();
});
// And some top-level UI appears (Navbar stub)
await waitFor(() => {
expect(screen.getByTestId("navbar")).toBeInTheDocument();
});
});
});