This commit is contained in:
Harshit Jain 2026-02-28 15:24:00 +05:30
commit 290ecf88f8
No known key found for this signature in database
GPG key ID: 36C392CD4415B4CF
91 changed files with 5791 additions and 844 deletions

View file

@ -330,6 +330,22 @@ model_list:
health_check_timeout: 10 # 👈 OVERRIDE HEALTH CHECK TIMEOUT
```
## Health Check Max Tokens
By default, health checks use `max_tokens=1` to minimize cost and latency. For wildcard models, the default is `max_tokens=10`.
You can override this per-model by setting `health_check_max_tokens` in the `model_info` section of your config.yaml.
```yaml
model_list:
- model_name: openai/gpt-4o
litellm_params:
model: openai/gpt-4o
api_key: os.environ/OPENAI_API_KEY
model_info:
health_check_max_tokens: 5 # 👈 OVERRIDE HEALTH CHECK MAX TOKENS
```
## `/health/readiness`
Unprotected endpoint for checking if proxy is ready to accept requests

View file

@ -113,6 +113,31 @@ litellm_settings:
```
## Pod Health Metrics
Use these to measure per-pod queue depth and diagnose latency that occurs **before** LiteLLM starts processing a request.
| Metric Name | Type | Description |
|---|---|---|
| `litellm_in_flight_requests` | Gauge | Number of HTTP requests currently in-flight on this uvicorn worker. Tracks the pod's queue depth in real time. With multiple workers, values are summed across all live workers (`livesum`). |
### When to use this
LiteLLM measures latency from when its handler starts. If a request waits in uvicorn's event loop before the handler runs, that wait is invisible to LiteLLM's own logs. `litellm_in_flight_requests` shows how loaded the pod was at any point in time.
```
high in_flight_requests + high ALB TargetResponseTime → pod overloaded, scale out
low in_flight_requests + high ALB TargetResponseTime → delay is pre-ASGI (event loop blocking)
```
You can also check the current value directly without Prometheus:
```bash
curl http://localhost:4000/health/backlog \
-H "Authorization: Bearer sk-..."
# {"in_flight_requests": 47}
```
## Proxy Level Tracking Metrics
Use this to track overall LiteLLM Proxy usage.

View file

@ -2,9 +2,41 @@
Use this guide when you see unexpected latency overhead between LiteLLM proxy and the LLM provider.
## The Invisible Latency Gap
LiteLLM measures latency from when its handler starts. If a request waits in uvicorn's event loop **before** the handler runs, that wait is invisible to LiteLLM's own logs.
```
T=0 Request arrives at load balancer
[queue wait — LiteLLM never logs this]
T=10 LiteLLM handler starts → timer begins
T=20 Response sent
LiteLLM logs: 10s User experiences: 20s
```
To measure the pre-handler wait, poll `/health/backlog` on each pod:
```bash
curl http://localhost:4000/health/backlog \
-H "Authorization: Bearer sk-..."
# {"in_flight_requests": 47}
```
Or scrape the `litellm_in_flight_requests` Prometheus gauge at `/metrics`.
| `in_flight_requests` | ALB `TargetResponseTime` | Diagnosis |
|---|---|---|
| High | High | Pod overloaded → scale out |
| Low | High | Delay is pre-ASGI — check for sync blocking code or event loop saturation |
| High | Normal | Pod is busy but healthy, no queue buildup |
If you're on **AWS ALB**, correlate `litellm_in_flight_requests` spikes with ALB's `TargetResponseTime` CloudWatch metric. The gap between what ALB reports and what LiteLLM logs is the invisible wait.
## Quick Checklist
1. **Collect the `x-litellm-overhead-duration-ms` response header** — this tells you LiteLLM's total overhead on every request. Start here.
1. **Check `in_flight_requests` on each pod** via `/health/backlog` or the `litellm_in_flight_requests` Prometheus gauge — this tells you if requests are queuing before LiteLLM starts processing. Start here for unexplained latency.
2. **Collect the `x-litellm-overhead-duration-ms` response header** — this tells you LiteLLM's total overhead on every request.
2. **Is DEBUG logging enabled?** This is the #1 cause of latency with large payloads.
3. **Are you sending large base64 payloads?** (images, PDFs) — see [Large Payload Overhead](#large-payload-overhead).
4. **Enable detailed timing headers** to pinpoint where time is spent.

View file

@ -0,0 +1,18 @@
-- CreateTable
CREATE TABLE "LiteLLM_ClaudeCodePluginTable" (
"id" TEXT NOT NULL,
"name" TEXT NOT NULL,
"version" TEXT,
"description" TEXT,
"manifest_json" TEXT,
"files_json" TEXT DEFAULT '{}',
"enabled" BOOLEAN NOT NULL DEFAULT true,
"created_at" TIMESTAMP(3) DEFAULT CURRENT_TIMESTAMP,
"updated_at" TIMESTAMP(3) DEFAULT CURRENT_TIMESTAMP,
"created_by" TEXT,
CONSTRAINT "LiteLLM_ClaudeCodePluginTable_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE UNIQUE INDEX "LiteLLM_ClaudeCodePluginTable_name_key" ON "LiteLLM_ClaudeCodePluginTable"("name");

View file

@ -300,7 +300,7 @@ model LiteLLM_MCPServerTable {
token_url String?
registration_url String?
allow_all_keys Boolean @default(false)
available_on_public_internet Boolean @default(false)
available_on_public_internet Boolean @default(true)
}
// Generate Tokens for Proxy

View file

@ -3,11 +3,37 @@ Add the event loop to the cache key, to prevent event loop closed errors.
"""
import asyncio
from typing import Set
from .in_memory_cache import InMemoryCache
class LLMClientCache(InMemoryCache):
# Background tasks must be stored to prevent garbage collection, which would
# trigger "coroutine was never awaited" warnings. See:
# https://docs.python.org/3/library/asyncio-task.html#creating-tasks
# Intentionally shared across all instances as a global task registry.
_background_tasks: Set[asyncio.Task] = set()
def _remove_key(self, key: str) -> None:
"""Close async clients before evicting them to prevent connection pool leaks."""
value = self.cache_dict.get(key)
super()._remove_key(key)
if value is not None:
close_fn = getattr(value, "aclose", None) or getattr(value, "close", None)
if close_fn and asyncio.iscoroutinefunction(close_fn):
try:
task = asyncio.get_running_loop().create_task(close_fn())
self._background_tasks.add(task)
task.add_done_callback(self._background_tasks.discard)
except RuntimeError:
pass
elif close_fn and callable(close_fn):
try:
close_fn()
except Exception:
pass
def update_cache_key_with_event_loop(self, key):
"""
Add the event loop to the cache key, to prevent event loop closed errors.

View file

@ -193,9 +193,9 @@ _DEFAULT_TTL_FOR_HTTPX_CLIENTS = 3600 # 1 hour, re-use the same httpx client fo
# Aiohttp connection pooling - prevents memory leaks from unbounded connection growth
# Set to 0 for unlimited (not recommended for production)
AIOHTTP_CONNECTOR_LIMIT = int(os.getenv("AIOHTTP_CONNECTOR_LIMIT", 300))
AIOHTTP_CONNECTOR_LIMIT = int(os.getenv("AIOHTTP_CONNECTOR_LIMIT", 1000))
AIOHTTP_CONNECTOR_LIMIT_PER_HOST = int(
os.getenv("AIOHTTP_CONNECTOR_LIMIT_PER_HOST", 50)
os.getenv("AIOHTTP_CONNECTOR_LIMIT_PER_HOST", 500)
)
AIOHTTP_KEEPALIVE_TIMEOUT = int(os.getenv("AIOHTTP_KEEPALIVE_TIMEOUT", 120))
AIOHTTP_TTL_DNS_CACHE = int(os.getenv("AIOHTTP_TTL_DNS_CACHE", 300))

View file

@ -483,6 +483,7 @@ def image_generation( # noqa: PLR0915
organization=organization,
aimg_generation=aimg_generation,
client=client,
headers=headers,
)
elif custom_llm_provider == "bedrock":
if model is None:

View file

@ -14,7 +14,6 @@ TEST_PDF_URL = "data:application/pdf;base64,JVBERi0xLjQKJeLjz9MKMyAwIG9iago8PC9U
class HealthCheckHelpers:
@staticmethod
async def ahealth_check_wildcard_models(
model: str,
@ -44,7 +43,9 @@ class HealthCheckHelpers:
model_params["model"] = cheapest_models[0]
model_params["litellm_logging_obj"] = litellm_logging_obj
model_params["fallbacks"] = fallback_models
model_params["max_tokens"] = 10 # gpt-5-nano throws errors for max_tokens=1
model_params["max_tokens"] = model_params.get(
"max_tokens", 10
) # gpt-5-nano throws errors for max_tokens=1
await acompletion(**model_params)
return {}
@ -130,7 +131,7 @@ class HealthCheckHelpers:
Callable,
]:
"""
Returns a dictionary of mode handlers for health check calls.
Returns a dictionary of mode handlers for health check calls.
Mode Handlers are Callables that need to be run for execution of the health check call.
@ -215,4 +216,4 @@ class HealthCheckHelpers:
"document_url": TEST_PDF_URL,
},
),
}
}

View file

@ -1968,22 +1968,24 @@ class CustomStreamWrapper:
self.rules.post_call_rules(
input=self.response_uptil_now, model=self.model
)
# Store a shallow copy so usage stripping below
# does not mutate the stored chunk.
self.chunks.append(processed_chunk.model_copy())
# Add mcp_list_tools to first chunk if present
if not self.sent_first_chunk:
processed_chunk = self._add_mcp_list_tools_to_first_chunk(processed_chunk)
self.sent_first_chunk = True
if (
_has_usage = (
hasattr(processed_chunk, "usage")
and getattr(processed_chunk, "usage", None) is not None
):
)
if _has_usage:
# Store a copy ONLY when usage stripping below will mutate
# the chunk. For non-usage chunks (vast majority), store
# directly to avoid expensive model_copy() per chunk.
self.chunks.append(processed_chunk.model_copy())
# Strip usage from the outgoing chunk so it's not sent twice
# (once in the chunk, once in _hidden_params).
# Create a new object without usage, matching sync behavior.
# The copy in self.chunks retains usage for calculate_total_usage().
obj_dict = processed_chunk.model_dump()
if "usage" in obj_dict:
del obj_dict["usage"]
@ -1995,6 +1997,9 @@ class CustomStreamWrapper:
)
if is_empty:
continue
else:
# No usage data — safe to store directly without copying
self.chunks.append(processed_chunk)
# add usage as hidden param
if self.sent_last_chunk is True and self.stream_options is None:

View file

@ -68,7 +68,7 @@ def make_sync_call(
model_response=model_response, json_mode=json_mode
)
else:
decoder = AWSEventStreamDecoder(model=model)
decoder = AWSEventStreamDecoder(model=model, json_mode=json_mode)
completion_stream = decoder.iter_bytes(response.iter_bytes(chunk_size=stream_chunk_size))
# LOGGING

View file

@ -511,6 +511,7 @@ class AmazonConverseConfig(BaseConfig):
"response_format",
"requestMetadata",
"service_tier",
"parallel_tool_calls",
]
if (
@ -913,6 +914,13 @@ class AmazonConverseConfig(BaseConfig):
)
if _tool_choice_value is not None:
optional_params["tool_choice"] = _tool_choice_value
if param == "parallel_tool_calls":
disable_parallel = not value
optional_params["_parallel_tool_use_config"] = {
"tool_choice": {
"disable_parallel_tool_use": disable_parallel
}
}
if param == "thinking":
optional_params["thinking"] = value
elif param == "reasoning_effort" and isinstance(value, str):
@ -1771,6 +1779,92 @@ class AmazonConverseConfig(BaseConfig):
return content_str, tools, reasoningContentBlocks, citationsContentBlocks
@staticmethod
def _unwrap_bedrock_properties(json_str: str) -> str:
"""
Unwrap Bedrock's response_format JSON structure.
If the JSON has a single "properties" key, extract its value.
Otherwise, return the original string.
Args:
json_str: JSON string to unwrap
Returns:
Unwrapped JSON string or original if unwrapping not needed
"""
try:
response_data = json.loads(json_str)
if (
isinstance(response_data, dict)
and "properties" in response_data
and len(response_data) == 1
):
response_data = response_data["properties"]
return json.dumps(response_data)
except json.JSONDecodeError:
pass
return json_str
@staticmethod
def _filter_json_mode_tools(
json_mode: Optional[bool],
tools: List[ChatCompletionToolCallChunk],
chat_completion_message: ChatCompletionResponseMessage,
) -> Optional[List[ChatCompletionToolCallChunk]]:
"""
When json_mode is True, Bedrock may return the internal `json_tool_call`
tool alongside real user-defined tools. This method handles 3 scenarios:
1. Only json_tool_call present -> convert to text content, return None
2. Mixed json_tool_call + real -> filter out json_tool_call, return real tools
3. No json_tool_call / no json_mode -> return tools as-is
"""
if not json_mode or not tools:
return tools if tools else None
json_tool_indices = [
i
for i, t in enumerate(tools)
if t["function"].get("name") == RESPONSE_FORMAT_TOOL_NAME
]
if not json_tool_indices:
# No json_tool_call found, return tools unchanged
return tools
if len(json_tool_indices) == len(tools):
# All tools are json_tool_call — convert first one to content
verbose_logger.debug(
"Processing JSON tool call response for response_format"
)
json_mode_content_str: Optional[str] = tools[0]["function"].get(
"arguments"
)
if json_mode_content_str is not None:
json_mode_content_str = AmazonConverseConfig._unwrap_bedrock_properties(
json_mode_content_str
)
chat_completion_message["content"] = json_mode_content_str
return None
# Mixed: filter out json_tool_call, keep real tools.
# Preserve the json_tool_call content as message text so the structured
# output from response_format is not silently lost.
first_idx = json_tool_indices[0]
json_mode_args = tools[first_idx]["function"].get("arguments")
if json_mode_args is not None:
json_mode_args = AmazonConverseConfig._unwrap_bedrock_properties(
json_mode_args
)
existing = chat_completion_message.get("content") or ""
chat_completion_message["content"] = (
existing + json_mode_args if existing else json_mode_args
)
real_tools = [t for i, t in enumerate(tools) if i not in json_tool_indices]
return real_tools if real_tools else None
def _transform_response( # noqa: PLR0915
self,
model: str,
@ -1793,7 +1887,7 @@ class AmazonConverseConfig(BaseConfig):
additional_args={"complete_input_dict": data},
)
json_mode: Optional[bool] = optional_params.pop("json_mode", None)
json_mode: Optional[bool] = optional_params.get("json_mode", None)
## RESPONSE OBJECT
try:
completion_response = ConverseResponseBlock(**response.json()) # type: ignore
@ -1877,37 +1971,13 @@ class AmazonConverseConfig(BaseConfig):
self._transform_thinking_blocks(reasoningContentBlocks)
)
chat_completion_message["content"] = content_str
if (
json_mode is True
and tools is not None
and len(tools) == 1
and tools[0]["function"].get("name") == RESPONSE_FORMAT_TOOL_NAME
):
verbose_logger.debug(
"Processing JSON tool call response for response_format"
)
json_mode_content_str: Optional[str] = tools[0]["function"].get("arguments")
if json_mode_content_str is not None:
# Bedrock returns the response wrapped in a "properties" object
# We need to extract the actual content from this wrapper
try:
response_data = json.loads(json_mode_content_str)
# If Bedrock wrapped the response in "properties", extract the content
if (
isinstance(response_data, dict)
and "properties" in response_data
and len(response_data) == 1
):
response_data = response_data["properties"]
json_mode_content_str = json.dumps(response_data)
except json.JSONDecodeError:
# If parsing fails, use the original response
pass
chat_completion_message["content"] = json_mode_content_str
elif tools:
chat_completion_message["tool_calls"] = tools
filtered_tools = self._filter_json_mode_tools(
json_mode=json_mode,
tools=tools,
chat_completion_message=chat_completion_message,
)
if filtered_tools:
chat_completion_message["tool_calls"] = filtered_tools
## CALCULATING USAGE - bedrock returns usage in the headers
usage = self._transform_usage(completion_response["usage"])

View file

@ -22,6 +22,7 @@ import litellm
from litellm import verbose_logger
from litellm._uuid import uuid
from litellm.caching.caching import InMemoryCache
from litellm.constants import RESPONSE_FORMAT_TOOL_NAME
from litellm.litellm_core_utils.core_helpers import map_finish_reason
from litellm.litellm_core_utils.litellm_logging import Logging
from litellm.litellm_core_utils.logging_utils import track_llm_api_timing
@ -252,7 +253,7 @@ async def make_call(
response.aiter_bytes(chunk_size=stream_chunk_size)
)
else:
decoder = AWSEventStreamDecoder(model=model)
decoder = AWSEventStreamDecoder(model=model, json_mode=json_mode)
completion_stream = decoder.aiter_bytes(
response.aiter_bytes(chunk_size=stream_chunk_size)
)
@ -346,7 +347,7 @@ def make_sync_call(
response.iter_bytes(chunk_size=stream_chunk_size)
)
else:
decoder = AWSEventStreamDecoder(model=model)
decoder = AWSEventStreamDecoder(model=model, json_mode=json_mode)
completion_stream = decoder.iter_bytes(
response.iter_bytes(chunk_size=stream_chunk_size)
)
@ -1282,7 +1283,7 @@ def get_response_stream_shape():
class AWSEventStreamDecoder:
def __init__(self, model: str) -> None:
def __init__(self, model: str, json_mode: Optional[bool] = False) -> None:
from botocore.parsers import EventStreamJSONParser
self.model = model
@ -1290,6 +1291,8 @@ class AWSEventStreamDecoder:
self.content_blocks: List[ContentBlockDeltaEvent] = []
self.tool_calls_index: Optional[int] = None
self.response_id: Optional[str] = None
self.json_mode = json_mode
self._current_tool_name: Optional[str] = None
def check_empty_tool_call_args(self) -> bool:
"""
@ -1391,6 +1394,16 @@ class AWSEventStreamDecoder:
response_tool_name = get_bedrock_tool_name(
response_tool_name=_response_tool_name
)
self._current_tool_name = response_tool_name
# When json_mode is True, suppress the internal json_tool_call
# and convert its content to text in delta events instead
if (
self.json_mode is True
and response_tool_name == RESPONSE_FORMAT_TOOL_NAME
):
return tool_use, provider_specific_fields, thinking_blocks
self.tool_calls_index = (
0 if self.tool_calls_index is None else self.tool_calls_index + 1
)
@ -1445,19 +1458,27 @@ class AWSEventStreamDecoder:
if "text" in delta_obj:
text = delta_obj["text"]
elif "toolUse" in delta_obj:
tool_use = {
"id": None,
"type": "function",
"function": {
"name": None,
"arguments": delta_obj["toolUse"]["input"],
},
"index": (
self.tool_calls_index
if self.tool_calls_index is not None
else index
),
}
# When json_mode is True and this is the internal json_tool_call,
# convert tool input to text content instead of tool call arguments
if (
self.json_mode is True
and self._current_tool_name == RESPONSE_FORMAT_TOOL_NAME
):
text = delta_obj["toolUse"]["input"]
else:
tool_use = {
"id": None,
"type": "function",
"function": {
"name": None,
"arguments": delta_obj["toolUse"]["input"],
},
"index": (
self.tool_calls_index
if self.tool_calls_index is not None
else index
),
}
elif "reasoningContent" in delta_obj:
provider_specific_fields = {
"reasoningContent": delta_obj["reasoningContent"],
@ -1494,6 +1515,17 @@ class AWSEventStreamDecoder:
) -> Optional[ChatCompletionToolCallChunk]:
"""Handle stop/contentBlockIndex event in converse chunk parsing."""
tool_use: Optional[ChatCompletionToolCallChunk] = None
# If the ending block was the internal json_tool_call, skip emitting
# the empty-args tool chunk and reset tracking state
if (
self.json_mode is True
and self._current_tool_name == RESPONSE_FORMAT_TOOL_NAME
):
self._current_tool_name = None
return tool_use
self._current_tool_name = None
is_empty = self.check_empty_tool_call_args()
if is_empty:
tool_use = {

View file

@ -1401,6 +1401,7 @@ class OpenAIChatCompletion(BaseLLM, BaseOpenAILLM):
client=None,
max_retries=None,
organization: Optional[str] = None,
headers: Optional[dict] = None,
):
response = None
try:
@ -1414,6 +1415,8 @@ class OpenAIChatCompletion(BaseLLM, BaseOpenAILLM):
client=client,
)
if headers:
data["extra_headers"] = headers
response = await openai_aclient.images.generate(**data, timeout=timeout) # type: ignore
stringified_response = response.model_dump()
## LOGGING
@ -1446,6 +1449,7 @@ class OpenAIChatCompletion(BaseLLM, BaseOpenAILLM):
client=None,
aimg_generation=None,
organization: Optional[str] = None,
headers: Optional[dict] = None,
) -> ImageResponse:
data = {}
try:
@ -1455,7 +1459,7 @@ class OpenAIChatCompletion(BaseLLM, BaseOpenAILLM):
raise OpenAIError(status_code=422, message="max retries must be an int")
if aimg_generation is True:
return self.aimage_generation(data=data, prompt=prompt, logging_obj=logging_obj, model_response=model_response, api_base=api_base, api_key=api_key, timeout=timeout, client=client, max_retries=max_retries, organization=organization) # type: ignore
return self.aimage_generation(data=data, prompt=prompt, logging_obj=logging_obj, model_response=model_response, api_base=api_base, api_key=api_key, timeout=timeout, client=client, max_retries=max_retries, organization=organization, headers=headers) # type: ignore
openai_client: OpenAI = self._get_openai_client( # type: ignore
is_async=False,
@ -1480,6 +1484,8 @@ class OpenAIChatCompletion(BaseLLM, BaseOpenAILLM):
)
## COMPLETION CALL
if headers:
data["extra_headers"] = headers
_response = openai_client.images.generate(**data, timeout=timeout) # type: ignore
response = _response.model_dump()

View file

@ -19210,6 +19210,39 @@
"supports_tool_choice": true,
"supports_vision": false
},
"gpt-audio-1.5": {
"input_cost_per_audio_token": 3.2e-05,
"input_cost_per_token": 2.5e-06,
"litellm_provider": "openai",
"max_input_tokens": 128000,
"max_output_tokens": 16384,
"max_tokens": 16384,
"mode": "chat",
"output_cost_per_audio_token": 6.4e-05,
"output_cost_per_token": 1e-05,
"supported_endpoints": [
"/v1/chat/completions"
],
"supported_modalities": [
"text",
"audio"
],
"supported_output_modalities": [
"text",
"audio"
],
"supports_audio_input": true,
"supports_audio_output": true,
"supports_function_calling": true,
"supports_native_streaming": true,
"supports_parallel_function_calling": true,
"supports_prompt_caching": false,
"supports_reasoning": false,
"supports_response_schema": false,
"supports_system_messages": true,
"supports_tool_choice": true,
"supports_vision": false
},
"gpt-audio-2025-08-28": {
"input_cost_per_audio_token": 3.2e-05,
"input_cost_per_token": 2.5e-06,
@ -20927,6 +20960,38 @@
"supports_system_messages": true,
"supports_tool_choice": true
},
"gpt-realtime-1.5": {
"cache_creation_input_audio_token_cost": 4e-07,
"cache_read_input_token_cost": 4e-07,
"input_cost_per_audio_token": 3.2e-05,
"input_cost_per_image": 5e-06,
"input_cost_per_token": 4e-06,
"litellm_provider": "openai",
"max_input_tokens": 32000,
"max_output_tokens": 4096,
"max_tokens": 4096,
"mode": "chat",
"output_cost_per_audio_token": 6.4e-05,
"output_cost_per_token": 1.6e-05,
"supported_endpoints": [
"/v1/realtime"
],
"supported_modalities": [
"text",
"image",
"audio"
],
"supported_output_modalities": [
"text",
"audio"
],
"supports_audio_input": true,
"supports_audio_output": true,
"supports_function_calling": true,
"supports_parallel_function_calling": true,
"supports_system_messages": true,
"supports_tool_choice": true
},
"gpt-realtime-mini": {
"cache_creation_input_audio_token_cost": 3e-07,
"cache_read_input_audio_token_cost": 3e-07,
@ -25092,6 +25157,25 @@
"supports_vision": true,
"tool_use_system_prompt_tokens": 159
},
"openrouter/anthropic/claude-opus-4.6": {
"cache_creation_input_token_cost": 6.25e-06,
"cache_read_input_token_cost": 5e-07,
"input_cost_per_token": 5e-06,
"litellm_provider": "openrouter",
"max_input_tokens": 1000000,
"max_output_tokens": 128000,
"max_tokens": 128000,
"mode": "chat",
"output_cost_per_token": 2.5e-05,
"supports_assistant_prefill": true,
"supports_computer_use": true,
"supports_function_calling": true,
"supports_prompt_caching": true,
"supports_reasoning": true,
"supports_tool_choice": true,
"supports_vision": true,
"tool_use_system_prompt_tokens": 346
},
"openrouter/anthropic/claude-sonnet-4.5": {
"input_cost_per_image": 0.0048,
"cache_creation_input_token_cost": 3.75e-06,
@ -26104,6 +26188,42 @@
"supports_prompt_caching": true,
"supports_computer_use": false
},
"openrouter/openrouter/auto": {
"input_cost_per_token": 0,
"output_cost_per_token": 0,
"litellm_provider": "openrouter",
"max_input_tokens": 2000000,
"max_tokens": 2000000,
"mode": "chat",
"supports_function_calling": true,
"supports_tool_choice": true,
"supports_reasoning": true,
"supports_response_schema": true,
"supports_vision": true,
"supports_audio_input": true,
"supports_video_input": true
},
"openrouter/openrouter/free": {
"input_cost_per_token": 0,
"output_cost_per_token": 0,
"litellm_provider": "openrouter",
"max_input_tokens": 200000,
"max_tokens": 200000,
"mode": "chat",
"supports_function_calling": true,
"supports_tool_choice": true,
"supports_reasoning": true,
"supports_response_schema": true,
"supports_vision": true
},
"openrouter/openrouter/bodybuilder": {
"input_cost_per_token": 0,
"output_cost_per_token": 0,
"litellm_provider": "openrouter",
"max_input_tokens": 128000,
"max_tokens": 128000,
"mode": "chat"
},
"ovhcloud/DeepSeek-R1-Distill-Llama-70B": {
"input_cost_per_token": 6.7e-07,
"litellm_provider": "ovhcloud",
@ -26650,8 +26770,8 @@
"mode": "chat",
"output_cost_per_token": 0.0,
"source": "https://platform.publicai.co/docs",
"supports_function_calling": true,
"supports_tool_choice": true
"supports_function_calling": false,
"supports_tool_choice": false
},
"publicai/swiss-ai/apertus-70b-instruct": {
"input_cost_per_token": 0.0,
@ -26662,8 +26782,8 @@
"mode": "chat",
"output_cost_per_token": 0.0,
"source": "https://platform.publicai.co/docs",
"supports_function_calling": true,
"supports_tool_choice": true
"supports_function_calling": false,
"supports_tool_choice": false
},
"publicai/aisingapore/Gemma-SEA-LION-v4-27B-IT": {
"input_cost_per_token": 0.0,
@ -32991,6 +33111,7 @@
"supports_web_search": true
},
"xai/grok-2-vision-1212": {
"deprecation_date": "2026-02-28",
"input_cost_per_image": 2e-06,
"input_cost_per_token": 2e-06,
"litellm_provider": "xai",
@ -33095,6 +33216,7 @@
},
"xai/grok-3-mini": {
"cache_read_input_token_cost": 7.5e-08,
"deprecation_date": "2026-02-28",
"input_cost_per_token": 3e-07,
"litellm_provider": "xai",
"max_input_tokens": 131072,
@ -33111,6 +33233,7 @@
},
"xai/grok-3-mini-beta": {
"cache_read_input_token_cost": 7.5e-08,
"deprecation_date": "2026-02-28",
"input_cost_per_token": 3e-07,
"litellm_provider": "xai",
"max_input_tokens": 131072,

View file

@ -331,7 +331,7 @@ class MCPServerManager:
static_headers=server_config.get("static_headers", None),
allow_all_keys=bool(server_config.get("allow_all_keys", False)),
available_on_public_internet=bool(
server_config.get("available_on_public_internet", False)
server_config.get("available_on_public_internet", True)
),
)
self.config_mcp_servers[server_id] = new_server
@ -634,7 +634,7 @@ class MCPServerManager:
disallowed_tools=getattr(mcp_server, "disallowed_tools", None),
allow_all_keys=mcp_server.allow_all_keys,
available_on_public_internet=bool(
getattr(mcp_server, "available_on_public_internet", False)
getattr(mcp_server, "available_on_public_internet", True)
),
updated_at=getattr(mcp_server, "updated_at", None),
)

View file

@ -431,6 +431,7 @@ class LiteLLMRoutes(enum.Enum):
agent_routes = [
"/v1/agents",
"/v1/agents/{agent_id}",
"/agents",
"/a2a/{agent_id}",
"/a2a/{agent_id}/message/send",
@ -1092,7 +1093,7 @@ class NewMCPServerRequest(LiteLLMPydanticObjectBase):
token_url: Optional[str] = None
registration_url: Optional[str] = None
allow_all_keys: bool = False
available_on_public_internet: bool = False
available_on_public_internet: bool = True
@model_validator(mode="before")
@classmethod
@ -1146,7 +1147,7 @@ class UpdateMCPServerRequest(LiteLLMPydanticObjectBase):
token_url: Optional[str] = None
registration_url: Optional[str] = None
allow_all_keys: bool = False
available_on_public_internet: bool = False
available_on_public_internet: bool = True
@model_validator(mode="before")
@classmethod
@ -1203,7 +1204,7 @@ class LiteLLM_MCPServerTable(LiteLLMPydanticObjectBase):
token_url: Optional[str] = None
registration_url: Optional[str] = None
allow_all_keys: bool = False
available_on_public_internet: bool = False
available_on_public_internet: bool = True
class MakeMCPServersPublicRequest(LiteLLMPydanticObjectBase):
@ -2416,6 +2417,7 @@ class UserAPIKeyAuth(
key_alias=LITELLM_INTERNAL_JOBS_SERVICE_ACCOUNT_NAME,
team_alias="system",
user_id="system",
user_role=LitellmUserRoles.PROXY_ADMIN,
)

View file

@ -31,6 +31,23 @@ from litellm.types.proxy.management_endpoints.common_daily_activity import (
router = APIRouter()
def _check_agent_management_permission(user_api_key_dict: UserAPIKeyAuth) -> None:
"""
Raises HTTP 403 if the caller does not have permission to create, update,
or delete agents. Only PROXY_ADMIN users are allowed to perform these
write operations.
"""
if user_api_key_dict.user_role != LitellmUserRoles.PROXY_ADMIN:
raise HTTPException(
status_code=403,
detail={
"error": "Only proxy admins can create, update, or delete agents. Your role={}".format(
user_api_key_dict.user_role
)
},
)
@router.get(
"/v1/agents",
tags=["[beta] A2A Agents"],
@ -164,6 +181,8 @@ async def create_agent(
"""
from litellm.proxy.proxy_server import prisma_client
_check_agent_management_permission(user_api_key_dict)
if prisma_client is None:
raise HTTPException(status_code=500, detail="Prisma client not initialized")
@ -302,6 +321,8 @@ async def update_agent(
"""
from litellm.proxy.proxy_server import prisma_client
_check_agent_management_permission(user_api_key_dict)
if prisma_client is None:
raise HTTPException(
status_code=500, detail=CommonProxyErrors.db_not_connected_error.value
@ -391,6 +412,8 @@ async def patch_agent(
"""
from litellm.proxy.proxy_server import prisma_client
_check_agent_management_permission(user_api_key_dict)
if prisma_client is None:
raise HTTPException(
status_code=500, detail=CommonProxyErrors.db_not_connected_error.value
@ -441,7 +464,10 @@ async def patch_agent(
tags=["Agents"],
dependencies=[Depends(user_api_key_auth)],
)
async def delete_agent(agent_id: str):
async def delete_agent(
agent_id: str,
user_api_key_dict: UserAPIKeyAuth = Depends(user_api_key_auth),
):
"""
Delete an agent
@ -460,6 +486,8 @@ async def delete_agent(agent_id: str):
"""
from litellm.proxy.proxy_server import prisma_client
_check_agent_management_permission(user_api_key_dict)
if prisma_client is None:
raise HTTPException(status_code=500, detail="Prisma client not initialized")

View file

@ -8,6 +8,7 @@ JWT token must have 'litellm_proxy_admin' in scope.
import fnmatch
import os
import re
from typing import Any, List, Literal, Optional, Set, Tuple, cast
from cryptography import x509
@ -235,7 +236,17 @@ class JWTHandler:
return self.litellm_jwtauth.team_id_default
else:
return default_value
# At this point, team_id is not the sentinel, so it should be a string
# AAD and other IdPs often send roles/groups as a list of strings.
# team_id_jwt_field is singular, so take the first element when a list
# is returned. This avoids "unhashable type: 'list'" errors downstream.
if isinstance(team_id, list):
if not team_id:
return default_value
verbose_proxy_logger.debug(
f"JWT Auth: team_id_jwt_field '{self.litellm_jwtauth.team_id_jwt_field}' "
f"returned a list {team_id}; using first element '{team_id[0]}' automatically."
)
team_id = team_id[0]
return team_id # type: ignore[return-value]
elif self.litellm_jwtauth.team_id_default is not None:
team_id = self.litellm_jwtauth.team_id_default
@ -453,6 +464,52 @@ class JWTHandler:
scopes = []
return scopes
async def _resolve_jwks_url(self, url: str) -> str:
"""
If url points to an OIDC discovery document (*.well-known/openid-configuration),
fetch it and return the jwks_uri contained within. Otherwise return url unchanged.
This lets JWT_PUBLIC_KEY_URL be set to a well-known discovery endpoint instead of
requiring operators to manually find the JWKS URL.
"""
if ".well-known/openid-configuration" not in url:
return url
cache_key = f"litellm_oidc_discovery_{url}"
cached_jwks_uri = await self.user_api_key_cache.async_get_cache(cache_key)
if cached_jwks_uri is not None:
return cached_jwks_uri
verbose_proxy_logger.debug(
f"JWT Auth: Fetching OIDC discovery document from {url}"
)
response = await self.http_handler.get(url)
if response.status_code != 200:
raise Exception(
f"JWT Auth: OIDC discovery endpoint {url} returned status {response.status_code}: {response.text}"
)
try:
discovery = response.json()
except Exception as e:
raise Exception(
f"JWT Auth: Failed to parse OIDC discovery document at {url}: {e}"
)
jwks_uri = discovery.get("jwks_uri")
if not jwks_uri:
raise Exception(
f"JWT Auth: OIDC discovery document at {url} does not contain a 'jwks_uri' field."
)
verbose_proxy_logger.debug(
f"JWT Auth: Resolved OIDC discovery {url} -> jwks_uri={jwks_uri}"
)
await self.user_api_key_cache.async_set_cache(
key=cache_key,
value=jwks_uri,
ttl=self.litellm_jwtauth.public_key_ttl,
)
return jwks_uri
async def get_public_key(self, kid: Optional[str]) -> dict:
keys_url = os.getenv("JWT_PUBLIC_KEY_URL")
@ -462,6 +519,7 @@ class JWTHandler:
keys_url_list = [url.strip() for url in keys_url.split(",")]
for key_url in keys_url_list:
key_url = await self._resolve_jwks_url(key_url)
cache_key = f"litellm_jwt_auth_keys_{key_url}"
cached_keys = await self.user_api_key_cache.async_get_cache(cache_key)
@ -913,8 +971,30 @@ class JWTAuthManager:
if jwt_handler.is_required_team_id() is True:
team_id_field = jwt_handler.litellm_jwtauth.team_id_jwt_field
team_alias_field = jwt_handler.litellm_jwtauth.team_alias_jwt_field
hint = ""
if team_id_field:
# "roles.0" — dot-notation numeric indexing is not supported
if "." in team_id_field:
parts = team_id_field.rsplit(".", 1)
if parts[-1].isdigit():
base_field = parts[0]
hint = (
f" Hint: dot-notation array indexing (e.g. '{team_id_field}') is not "
f"supported. Use '{base_field}' instead — LiteLLM automatically "
f"uses the first element when the field value is a list."
)
# "roles[0]" — bracket-notation indexing is also not supported in get_nested_value
elif "[" in team_id_field and team_id_field.endswith("]"):
m = re.match(r"^(\w+)\[(\d+)\]$", team_id_field)
if m:
base_field = m.group(1)
hint = (
f" Hint: array indexing (e.g. '{team_id_field}') is not supported "
f"in team_id_jwt_field. Use '{base_field}' instead — LiteLLM "
f"automatically uses the first element when the field value is a list."
)
raise Exception(
f"No team found in token. Checked team_id field '{team_id_field}' and team_alias field '{team_alias_field}'"
f"No team found in token. Checked team_id field '{team_id_field}' and team_alias field '{team_alias_field}'.{hint}"
)
return individual_team_id, team_object

View file

@ -18,6 +18,7 @@ Run a specific eval:
import json
import os
import re
import time
from datetime import datetime, timezone
from typing import Any, Dict, List
@ -105,7 +106,25 @@ def _print_confusion_report(label: str, metrics: dict, wrong: list) -> None:
def _save_confusion_results(label: str, metrics: dict, wrong: list, rows: list) -> dict:
"""Save confusion matrix results to a JSON file and return the result dict."""
os.makedirs(RESULTS_DIR, exist_ok=True)
safe_label = label.lower().replace(" ", "_").replace("", "-")
# Build a short, filesystem-safe filename from the label.
# Full label is preserved inside the JSON; filename just needs to be
# unique and recognisable. Format: {topic}_{method_abbrev}.json
parts = label.split("\u2014")
topic = parts[0].strip().lower().replace("block ", "").replace(" ", "_")
method_full = parts[1].strip() if len(parts) > 1 else ""
method_name = re.sub(r"\s*\(.*?\)", "", method_full).strip().lower()
qualifier_match = re.search(r"\(([^)]+)\)", method_full)
qualifier = qualifier_match.group(1) if qualifier_match else ""
qualifier = re.sub(r"\.[a-z]+$", "", qualifier) # drop .yaml etc.
if method_name == "contentfilter":
safe_label = f"{topic}_cf"
elif qualifier:
safe_label = f"{topic}_{method_name}_{qualifier}"
else:
safe_label = f"{topic}_{method_name}"
safe_label = safe_label.replace(" ", "_")
safe_label = re.sub(r"[^a-z0-9_.\-]", "", safe_label)
safe_label = re.sub(r"_+", "_", safe_label).strip("_")
result = {
"label": label,
"timestamp": datetime.now(timezone.utc).isoformat(),

View file

@ -234,6 +234,14 @@ def _update_litellm_params_for_health_check(
- for Bedrock models with region routing (bedrock/region/model), strips the litellm routing prefix but preserves the model ID
"""
litellm_params["messages"] = _get_random_llm_message()
_health_check_max_tokens = model_info.get("health_check_max_tokens", None)
if _health_check_max_tokens is not None:
litellm_params["max_tokens"] = _health_check_max_tokens
elif "*" not in (
model_info.get("health_check_model") or litellm_params.get("model") or ""
):
litellm_params["max_tokens"] = 1
_health_check_model = model_info.get("health_check_model", None)
if _health_check_model is not None:
litellm_params["model"] = _health_check_model
@ -321,7 +329,9 @@ async def perform_health_check(
# Filter by model_id first so a single deployment is checked when id is specified
if model_id is not None:
_by_id = [x for x in model_list if (x.get("model_info") or {}).get("id") == model_id]
_by_id = [
x for x in model_list if (x.get("model_info") or {}).get("id") == model_id
]
if _by_id:
model_list = _by_id
elif model is not None:

View file

@ -33,6 +33,9 @@ from litellm.proxy.health_check import (
perform_health_check,
run_with_timeout,
)
from litellm.proxy.middleware.in_flight_requests_middleware import (
get_in_flight_requests,
)
from litellm.secret_managers.main import get_secret
#### Health ENDPOINTS ####
@ -1297,6 +1300,23 @@ async def health_readiness():
raise HTTPException(status_code=503, detail=f"Service Unhealthy ({str(e)})")
@router.get(
"/health/backlog",
tags=["health"],
dependencies=[Depends(user_api_key_auth)],
)
async def health_backlog():
"""
Returns the number of HTTP requests currently in-flight on this uvicorn worker.
Use this to measure per-pod queue depth. A high value means the worker is
processing many concurrent requests requests arriving now will have to wait
for the event loop to get to them, adding latency before LiteLLM even starts
its own timer.
"""
return {"in_flight_requests": get_in_flight_requests()}
@router.get(
"/health/liveliness", # Historical LiteLLM name; doesn't match k8s terminology but kept for backwards compatibility
tags=["health"],

View file

@ -614,7 +614,7 @@ async def user_info(
user_id is None
and user_api_key_dict.user_role == LitellmUserRoles.PROXY_ADMIN
):
return await _get_user_info_for_proxy_admin()
return await _get_user_info_for_proxy_admin(user_api_key_dict=user_api_key_dict)
elif user_id is None:
user_id = user_api_key_dict.user_id
## GET USER ROW ##
@ -714,7 +714,7 @@ async def user_info(
raise handle_exception_on_proxy(e)
async def _get_user_info_for_proxy_admin():
async def _get_user_info_for_proxy_admin(user_api_key_dict: UserAPIKeyAuth):
"""
Admin UI Endpoint - Returns All Teams and Keys when Proxy Admin is querying
@ -754,9 +754,23 @@ async def _get_user_info_for_proxy_admin():
_teams_in_db = [LiteLLM_TeamTable(**team) for team in _teams_in_db]
_teams_in_db.sort(key=lambda x: (getattr(x, "team_alias", "") or ""))
returned_keys = _process_keys_for_user_info(keys=keys_in_db, all_teams=_teams_in_db)
# Get admin's own user_id and user_info
admin_user_id = user_api_key_dict.user_id
admin_user_info = None
if admin_user_id is not None:
admin_user_info = await prisma_client.get_data(user_id=admin_user_id)
if admin_user_info is not None:
admin_user_info = (
admin_user_info.model_dump()
if isinstance(admin_user_info, BaseModel)
else admin_user_info
)
return UserInfoResponse(
user_id=None,
user_info=None,
user_id=admin_user_id,
user_info=admin_user_info,
keys=returned_keys,
teams=_teams_in_db,
)

View file

@ -3983,6 +3983,10 @@ async def list_keys(
status: Optional[str] = Query(
None, description="Filter by status (e.g. 'deleted')"
),
project_id: Optional[str] = Query(None, description="Filter keys by project ID"),
access_group_id: Optional[str] = Query(
None, description="Filter keys by access group ID"
),
) -> KeyListResponseObject:
"""
List all keys for a given user / team / organization.
@ -4076,6 +4080,8 @@ async def list_keys(
sort_order=sort_order,
expand=expand,
status=status,
project_id=project_id,
access_group_id=access_group_id,
)
verbose_proxy_logger.debug("Successfully prepared response")
@ -4252,6 +4258,8 @@ def _build_key_filter_conditions(
admin_team_ids: Optional[List[str]],
member_team_ids: Optional[List[str]] = None,
include_created_by_keys: bool = False,
project_id: Optional[str] = None,
access_group_id: Optional[str] = None,
) -> Dict[str, Union[str, Dict[str, Any], List[Dict[str, Any]]]]:
"""Build filter conditions for key listing.
@ -4343,6 +4351,13 @@ def _build_key_filter_conditions(
elif len(or_conditions) == 1:
where.update(or_conditions[0])
# Apply project_id and access_group_id as global AND filters so they
# narrow results across all visibility conditions (own keys, team keys, etc.)
if project_id:
where = {"AND": [where, {"project_id": project_id}]}
if access_group_id:
where = {"AND": [where, {"access_group_ids": {"hasSome": [access_group_id]}}]}
verbose_proxy_logger.debug(f"Filter conditions: {where}")
return where
@ -4369,6 +4384,8 @@ async def _list_key_helper(
sort_order: str = "desc",
expand: Optional[List[str]] = None,
status: Optional[str] = None,
project_id: Optional[str] = None,
access_group_id: Optional[str] = None,
) -> KeyListResponseObject:
"""
Helper function to list keys
@ -4402,6 +4419,8 @@ async def _list_key_helper(
admin_team_ids=admin_team_ids,
member_team_ids=member_team_ids,
include_created_by_keys=include_created_by_keys,
project_id=project_id,
access_group_id=access_group_id,
)
# Calculate skip for pagination

View file

@ -0,0 +1,81 @@
"""
Tracks the number of HTTP requests currently in-flight on this uvicorn worker.
Used by /health/backlog to expose per-pod queue depth, and emitted as the
Prometheus gauge `litellm_in_flight_requests`.
"""
import os
from typing import Optional
from starlette.types import ASGIApp, Receive, Scope, Send
class InFlightRequestsMiddleware:
"""
ASGI middleware that increments a counter when a request arrives and
decrements it when the response is sent (or an error occurs).
The counter is class-level and therefore scoped to a single uvicorn worker
process exactly the per-pod granularity we want.
Also updates the `litellm_in_flight_requests` Prometheus gauge if
prometheus_client is installed. The gauge is lazily initialised on the
first request so that PROMETHEUS_MULTIPROC_DIR is already set by the time
we register the metric. Initialisation is attempted only once if
prometheus_client is absent the class remembers and never retries.
"""
_in_flight: int = 0
_gauge: Optional[object] = None
_gauge_init_attempted: bool = False
def __init__(self, app: ASGIApp) -> None:
self.app = app
async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None:
if scope["type"] != "http":
await self.app(scope, receive, send)
return
InFlightRequestsMiddleware._in_flight += 1
gauge = InFlightRequestsMiddleware._get_gauge()
if gauge is not None:
gauge.inc() # type: ignore[union-attr]
try:
await self.app(scope, receive, send)
finally:
InFlightRequestsMiddleware._in_flight -= 1
if gauge is not None:
gauge.dec() # type: ignore[union-attr]
@staticmethod
def get_count() -> int:
"""Return the number of HTTP requests currently in-flight."""
return InFlightRequestsMiddleware._in_flight
@staticmethod
def _get_gauge() -> Optional[object]:
if InFlightRequestsMiddleware._gauge_init_attempted:
return InFlightRequestsMiddleware._gauge
InFlightRequestsMiddleware._gauge_init_attempted = True
try:
from prometheus_client import Gauge
kwargs = {}
if "PROMETHEUS_MULTIPROC_DIR" in os.environ:
# livesum aggregates across all worker processes in the scrape response
kwargs["multiprocess_mode"] = "livesum"
InFlightRequestsMiddleware._gauge = Gauge(
"litellm_in_flight_requests",
"Number of HTTP requests currently in-flight on this uvicorn worker",
**kwargs,
)
except Exception:
InFlightRequestsMiddleware._gauge = None
return InFlightRequestsMiddleware._gauge
def get_in_flight_requests() -> int:
"""Module-level convenience wrapper used by the /health/backlog endpoint."""
return InFlightRequestsMiddleware.get_count()

View file

@ -424,6 +424,9 @@ from litellm.proxy.management_endpoints.user_agent_analytics_endpoints import (
router as user_agent_analytics_router,
)
from litellm.proxy.management_helpers.audit_logs import create_audit_log_for_update
from litellm.proxy.middleware.in_flight_requests_middleware import (
InFlightRequestsMiddleware,
)
from litellm.proxy.middleware.prometheus_auth_middleware import PrometheusAuthMiddleware
from litellm.proxy.ocr_endpoints.endpoints import router as ocr_router
from litellm.proxy.openai_evals_endpoints.endpoints import router as evals_router
@ -1404,6 +1407,7 @@ app.add_middleware(
)
app.add_middleware(PrometheusAuthMiddleware)
app.add_middleware(InFlightRequestsMiddleware)
def mount_swagger_ui():
@ -5298,13 +5302,15 @@ async def async_data_generator(
):
verbose_proxy_logger.debug("inside generator")
try:
# Use a list to accumulate response segments to avoid O(n^2) string concatenation
str_so_far_parts: list[str] = []
error_message: Optional[str] = None
requested_model_from_client = _get_client_requested_model_for_streaming(
request_data=request_data
)
model_mismatch_logged = False
# Use a running string instead of list + join to avoid O(n^2) overhead.
# Previously "".join(str_so_far_parts) was called every chunk, re-joining
# the entire accumulated response. String += is O(n) amortized total.
_str_so_far: str = ""
async for chunk in proxy_logging_obj.async_post_call_streaming_iterator_hook(
user_api_key_dict=user_api_key_dict,
response=response,
@ -5315,12 +5321,12 @@ async def async_data_generator(
user_api_key_dict=user_api_key_dict,
response=chunk,
data=request_data,
str_so_far="".join(str_so_far_parts),
str_so_far=_str_so_far if _str_so_far else None,
)
if isinstance(chunk, (ModelResponse, ModelResponseStream)):
response_str = litellm.get_response_string(response_obj=chunk)
str_so_far_parts.append(response_str)
_str_so_far += response_str
chunk, model_mismatch_logged = _restamp_streaming_chunk_model(
chunk=chunk,

View file

@ -300,7 +300,7 @@ model LiteLLM_MCPServerTable {
token_url String?
registration_url String?
allow_all_keys Boolean @default(false)
available_on_public_internet Boolean @default(false)
available_on_public_internet Boolean @default(true)
}
// Generate Tokens for Proxy

View file

@ -23,23 +23,31 @@ from typing import (
)
from litellm import _custom_logger_compatible_callbacks_literal
from litellm.constants import (DEFAULT_MODEL_CREATED_AT_TIME,
MAX_TEAM_LIST_LIMIT)
from litellm.proxy._types import (DB_CONNECTION_ERROR_TYPES, CommonProxyErrors,
ProxyErrorTypes, ProxyException,
SpendLogsMetadata, SpendLogsPayload)
from litellm.constants import DEFAULT_MODEL_CREATED_AT_TIME, MAX_TEAM_LIST_LIMIT
from litellm.proxy._types import (
DB_CONNECTION_ERROR_TYPES,
CommonProxyErrors,
ProxyErrorTypes,
ProxyException,
SpendLogsMetadata,
SpendLogsPayload,
)
from litellm.types.guardrails import GuardrailEventHooks
from litellm.types.utils import CallTypes, CallTypesLiteral
try:
from litellm_enterprise.enterprise_callbacks.send_emails.base_email import \
BaseEmailLogger
from litellm_enterprise.enterprise_callbacks.send_emails.resend_email import \
ResendEmailLogger
from litellm_enterprise.enterprise_callbacks.send_emails.sendgrid_email import \
SendGridEmailLogger
from litellm_enterprise.enterprise_callbacks.send_emails.smtp_email import \
SMTPEmailLogger
from litellm_enterprise.enterprise_callbacks.send_emails.base_email import (
BaseEmailLogger,
)
from litellm_enterprise.enterprise_callbacks.send_emails.resend_email import (
ResendEmailLogger,
)
from litellm_enterprise.enterprise_callbacks.send_emails.sendgrid_email import (
SendGridEmailLogger,
)
from litellm_enterprise.enterprise_callbacks.send_emails.smtp_email import (
SMTPEmailLogger,
)
except ImportError:
BaseEmailLogger = None # type: ignore
SendGridEmailLogger = None # type: ignore
@ -58,56 +66,70 @@ from fastapi import HTTPException, status
import litellm
import litellm.litellm_core_utils
import litellm.litellm_core_utils.litellm_logging
from litellm import (EmbeddingResponse, ImageResponse, ModelResponse,
ModelResponseStream, Router)
from litellm import (
EmbeddingResponse,
ImageResponse,
ModelResponse,
ModelResponseStream,
Router,
)
from litellm._logging import verbose_proxy_logger
from litellm._service_logger import ServiceLogging, ServiceTypes
from litellm.caching.caching import DualCache, RedisCache
from litellm.caching.dual_cache import LimitedSizeOrderedDict
from litellm.exceptions import RejectedRequestError
from litellm.integrations.custom_guardrail import (CustomGuardrail,
ModifyResponseException)
from litellm.integrations.custom_guardrail import (
CustomGuardrail,
ModifyResponseException,
)
from litellm.integrations.custom_logger import CustomLogger
from litellm.integrations.SlackAlerting.slack_alerting import SlackAlerting
from litellm.integrations.SlackAlerting.utils import \
_add_langfuse_trace_id_to_alert
from litellm.integrations.SlackAlerting.utils import _add_langfuse_trace_id_to_alert
from litellm.litellm_core_utils.litellm_logging import Logging
from litellm.litellm_core_utils.safe_json_dumps import safe_dumps
from litellm.litellm_core_utils.safe_json_loads import safe_json_loads
from litellm.llms.custom_httpx.http_handler import AsyncHTTPHandler
from litellm.proxy._types import (AlertType, CallInfo,
LiteLLM_VerificationTokenView, Member,
UserAPIKeyAuth)
from litellm.proxy._types import (
AlertType,
CallInfo,
LiteLLM_VerificationTokenView,
Member,
UserAPIKeyAuth,
)
from litellm.proxy.auth.route_checks import RouteChecks
from litellm.proxy.db.create_views import (create_missing_views,
should_create_missing_views)
from litellm.proxy.db.create_views import (
create_missing_views,
should_create_missing_views,
)
from litellm.proxy.db.db_spend_update_writer import DBSpendUpdateWriter
from litellm.proxy.db.exception_handler import PrismaDBExceptionHandler
from litellm.proxy.db.log_db_metrics import log_db_metrics
from litellm.proxy.db.prisma_client import PrismaWrapper
from litellm.proxy.guardrails.guardrail_hooks.unified_guardrail.unified_guardrail import \
UnifiedLLMGuardrails
from litellm.proxy.guardrails.guardrail_hooks.unified_guardrail.unified_guardrail import (
UnifiedLLMGuardrails,
)
from litellm.proxy.hooks import PROXY_HOOKS, get_proxy_hook
from litellm.proxy.hooks.cache_control_check import _PROXY_CacheControlCheck
from litellm.proxy.hooks.max_budget_limiter import _PROXY_MaxBudgetLimiter
from litellm.proxy.hooks.parallel_request_limiter import \
_PROXY_MaxParallelRequestsHandler
from litellm.proxy.hooks.parallel_request_limiter import (
_PROXY_MaxParallelRequestsHandler,
)
from litellm.proxy.litellm_pre_call_utils import LiteLLMProxyRequestSetup
from litellm.proxy.policy_engine.pipeline_executor import PipelineExecutor
from litellm.secret_managers.main import str_to_bool
from litellm.types.integrations.slack_alerting import DEFAULT_ALERT_TYPES
from litellm.types.mcp import (MCPDuringCallResponseObject,
MCPPreCallRequestObject,
MCPPreCallResponseObject)
from litellm.types.proxy.policy_engine.pipeline_types import \
PipelineExecutionResult
from litellm.types.mcp import (
MCPDuringCallResponseObject,
MCPPreCallRequestObject,
MCPPreCallResponseObject,
)
from litellm.types.proxy.policy_engine.pipeline_types import PipelineExecutionResult
from litellm.types.utils import LLMResponseTypes, LoggedLiteLLMParams
if TYPE_CHECKING:
from opentelemetry.trace import Span as _Span
from litellm.litellm_core_utils.litellm_logging import \
Logging as LiteLLMLoggingObj
from litellm.litellm_core_utils.litellm_logging import Logging as LiteLLMLoggingObj
Span = Union[_Span, Any]
else:
@ -1050,9 +1072,10 @@ class ProxyLogging:
"""Process prompt template if applicable."""
from litellm.proxy.prompts.prompt_endpoints import (
construct_versioned_prompt_id, get_latest_version_prompt_id)
from litellm.proxy.prompts.prompt_registry import \
IN_MEMORY_PROMPT_REGISTRY
construct_versioned_prompt_id,
get_latest_version_prompt_id,
)
from litellm.proxy.prompts.prompt_registry import IN_MEMORY_PROMPT_REGISTRY
from litellm.utils import get_non_default_completion_params
if prompt_version is None:
@ -1102,8 +1125,9 @@ class ProxyLogging:
def _process_guardrail_metadata(self, data: dict) -> None:
"""Process guardrails from metadata and add to applied_guardrails."""
from litellm.proxy.common_utils.callback_utils import \
add_guardrail_to_applied_guardrails_header
from litellm.proxy.common_utils.callback_utils import (
add_guardrail_to_applied_guardrails_header,
)
metadata_standard = data.get("metadata") or {}
metadata_litellm = data.get("litellm_metadata") or {}
@ -2000,27 +2024,32 @@ class ProxyLogging:
if isinstance(response, (ModelResponse, ModelResponseStream)):
response_str = litellm.get_response_string(response_obj=response)
elif isinstance(response, dict) and self.is_a2a_streaming_response(response):
from litellm.llms.a2a.common_utils import \
extract_text_from_a2a_response
from litellm.llms.a2a.common_utils import extract_text_from_a2a_response
response_str = extract_text_from_a2a_response(response)
if response_str is not None:
# Cache model-level guardrails check per-request to avoid repeated
# dict lookups + llm_router.get_deployment() per callback per chunk.
_cached_guardrail_data: Optional[dict] = None
_guardrail_data_computed = False
for callback in litellm.callbacks:
try:
_callback: Optional[CustomLogger] = None
if isinstance(callback, CustomGuardrail):
# Main - V2 Guardrails implementation
from litellm.types.guardrails import \
GuardrailEventHooks
from litellm.types.guardrails import GuardrailEventHooks
## CHECK FOR MODEL-LEVEL GUARDRAILS
modified_data = _check_and_merge_model_level_guardrails(
data=data, llm_router=llm_router
)
## CHECK FOR MODEL-LEVEL GUARDRAILS (cached per-request)
if not _guardrail_data_computed:
_cached_guardrail_data = _check_and_merge_model_level_guardrails(
data=data, llm_router=llm_router
)
_guardrail_data_computed = True
if (
callback.should_run_guardrail(
data=modified_data,
data=_cached_guardrail_data,
event_type=GuardrailEventHooks.post_call,
)
is not True
@ -4626,8 +4655,9 @@ async def update_spend_logs_job(
# Guardrail/policy usage tracking (same batch, outside spend-logs update)
try:
from litellm.proxy.guardrails.usage_tracking import \
process_spend_logs_guardrail_usage
from litellm.proxy.guardrails.usage_tracking import (
process_spend_logs_guardrail_usage,
)
await process_spend_logs_guardrail_usage(
prisma_client=prisma_client,
logs_to_process=logs_to_process,
@ -4653,8 +4683,10 @@ async def _monitor_spend_logs_queue(
db_writer_client: Optional HTTP handler for external spend logs endpoint
proxy_logging_obj: Proxy logging object
"""
from litellm.constants import (SPEND_LOG_QUEUE_POLL_INTERVAL,
SPEND_LOG_QUEUE_SIZE_THRESHOLD)
from litellm.constants import (
SPEND_LOG_QUEUE_POLL_INTERVAL,
SPEND_LOG_QUEUE_SIZE_THRESHOLD,
)
threshold = SPEND_LOG_QUEUE_SIZE_THRESHOLD
base_interval = SPEND_LOG_QUEUE_POLL_INTERVAL
@ -5175,11 +5207,12 @@ async def get_available_models_for_user(
List of model names available to the user
"""
from litellm.proxy.auth.auth_checks import get_team_object
from litellm.proxy.auth.model_checks import (get_complete_model_list,
get_key_models,
get_team_models)
from litellm.proxy.management_endpoints.team_endpoints import \
validate_membership
from litellm.proxy.auth.model_checks import (
get_complete_model_list,
get_key_models,
get_team_models,
)
from litellm.proxy.management_endpoints.team_endpoints import validate_membership
# Get proxy model list and access groups
if llm_router is None:

View file

@ -177,6 +177,19 @@ async def aresponses_api_with_mcp(
"litellm_metadata", {}
).get("user_api_key_auth")
# Extract MCP auth headers from request (for dynamic auth when fetching tools)
mcp_auth_header: Optional[str] = None
mcp_server_auth_headers: Optional[Dict[str, Dict[str, str]]] = None
secret_fields = kwargs.get("secret_fields")
if secret_fields and isinstance(secret_fields, dict):
from litellm.responses.utils import ResponsesAPIRequestUtils
mcp_auth_header, mcp_server_auth_headers, _, _ = (
ResponsesAPIRequestUtils.extract_mcp_headers_from_request(
secret_fields=secret_fields, tools=tools
)
)
# Get original MCP tools (for events) and OpenAI tools (for LLM) by reusing existing methods
(
original_mcp_tools,
@ -185,6 +198,8 @@ async def aresponses_api_with_mcp(
user_api_key_auth=user_api_key_auth,
mcp_tools_with_litellm_proxy=mcp_tools_with_litellm_proxy,
litellm_trace_id=kwargs.get("litellm_trace_id"),
mcp_auth_header=mcp_auth_header,
mcp_server_auth_headers=mcp_server_auth_headers,
)
openai_tools = LiteLLM_Proxy_MCP_Handler._transform_mcp_tools_to_openai(
original_mcp_tools
@ -370,6 +385,8 @@ async def aresponses_api_with_mcp(
) = await LiteLLM_Proxy_MCP_Handler._process_mcp_tools_without_openai_transform(
user_api_key_auth=user_api_key_auth,
mcp_tools_with_litellm_proxy=mcp_tools_with_litellm_proxy,
mcp_auth_header=mcp_auth_header,
mcp_server_auth_headers=mcp_server_auth_headers,
)
final_response = (
LiteLLM_Proxy_MCP_Handler._add_mcp_output_elements_to_response(

View file

@ -120,7 +120,18 @@ async def acompletion_with_mcp( # noqa: PLR0915
(kwargs.get("metadata", {}) or {}).get("user_api_key_auth")
)
# Process MCP tools
# Extract MCP auth headers before fetching tools (needed for dynamic auth)
(
mcp_auth_header,
mcp_server_auth_headers,
oauth2_headers,
raw_headers,
) = ResponsesAPIRequestUtils.extract_mcp_headers_from_request(
secret_fields=kwargs.get("secret_fields"),
tools=tools,
)
# Process MCP tools (pass auth headers for dynamic auth)
(
deduplicated_mcp_tools,
tool_server_map,
@ -128,6 +139,8 @@ async def acompletion_with_mcp( # noqa: PLR0915
user_api_key_auth=user_api_key_auth,
mcp_tools_with_litellm_proxy=mcp_tools_with_litellm_proxy,
litellm_trace_id=kwargs.get("litellm_trace_id"),
mcp_auth_header=mcp_auth_header,
mcp_server_auth_headers=mcp_server_auth_headers,
)
openai_tools = LiteLLM_Proxy_MCP_Handler._transform_mcp_tools_to_openai(
@ -143,17 +156,6 @@ async def acompletion_with_mcp( # noqa: PLR0915
mcp_tools_with_litellm_proxy=mcp_tools_with_litellm_proxy
)
# Extract MCP auth headers
(
mcp_auth_header,
mcp_server_auth_headers,
oauth2_headers,
raw_headers,
) = ResponsesAPIRequestUtils.extract_mcp_headers_from_request(
secret_fields=kwargs.get("secret_fields"),
tools=tools,
)
# Prepare call parameters
# Remove keys that shouldn't be passed to acompletion
clean_kwargs = {k: v for k, v in kwargs.items() if k not in ["acompletion"]}

View file

@ -99,6 +99,8 @@ class LiteLLM_Proxy_MCP_Handler:
user_api_key_auth: Any,
mcp_tools_with_litellm_proxy: Optional[Iterable[ToolParam]],
litellm_trace_id: Optional[str] = None,
mcp_auth_header: Optional[str] = None,
mcp_server_auth_headers: Optional[Dict[str, Dict[str, str]]] = None,
) -> tuple[List[MCPTool], List[str]]:
"""
Get available tools from the MCP server manager.
@ -106,6 +108,8 @@ class LiteLLM_Proxy_MCP_Handler:
Args:
user_api_key_auth: User authentication info for access control
mcp_tools_with_litellm_proxy: ToolParam objects with server_url starting with "litellm_proxy"
mcp_auth_header: Optional deprecated auth header for MCP servers
mcp_server_auth_headers: Optional server-specific auth headers (e.g. from x-mcp-{alias}-*)
Returns:
List of MCP tools
@ -133,13 +137,14 @@ class LiteLLM_Proxy_MCP_Handler:
tools = await _get_tools_from_mcp_servers(
user_api_key_auth=user_api_key_auth,
mcp_auth_header=None,
mcp_auth_header=mcp_auth_header,
mcp_servers=mcp_servers,
mcp_server_auth_headers=None,
mcp_server_auth_headers=mcp_server_auth_headers,
log_list_tools_to_spendlogs=True,
list_tools_log_source="responses",
litellm_trace_id=litellm_trace_id,
)
allowed_mcp_server_ids = (
await global_mcp_server_manager.get_allowed_mcp_servers(user_api_key_auth)
)
@ -278,6 +283,8 @@ class LiteLLM_Proxy_MCP_Handler:
user_api_key_auth: Any,
mcp_tools_with_litellm_proxy: List[ToolParam],
litellm_trace_id: Optional[str] = None,
mcp_auth_header: Optional[str] = None,
mcp_server_auth_headers: Optional[Dict[str, Dict[str, str]]] = None,
) -> tuple[List[Any], dict[str, str]]:
"""
Process MCP tools through filtering and deduplication pipeline without OpenAI transformation.
@ -286,6 +293,8 @@ class LiteLLM_Proxy_MCP_Handler:
Args:
user_api_key_auth: User authentication info for access control
mcp_tools_with_litellm_proxy: ToolParam objects with server_url starting with "litellm_proxy"
mcp_auth_header: Optional deprecated auth header for MCP servers
mcp_server_auth_headers: Optional server-specific auth headers (e.g. from x-mcp-{alias}-*)
Returns:
List of filtered and deduplicated MCP tools in their original format
@ -301,6 +310,8 @@ class LiteLLM_Proxy_MCP_Handler:
user_api_key_auth=user_api_key_auth,
mcp_tools_with_litellm_proxy=mcp_tools_with_litellm_proxy,
litellm_trace_id=litellm_trace_id,
mcp_auth_header=mcp_auth_header,
mcp_server_auth_headers=mcp_server_auth_headers,
)
# Step 2: Filter tools based on allowed_tools parameter

View file

@ -237,6 +237,7 @@ DEFINED_PROMETHEUS_METRICS = Literal[
"litellm_remaining_api_key_tokens_for_model",
"litellm_llm_api_failed_requests_metric",
"litellm_callback_logging_failures_metric",
"litellm_in_flight_requests",
]

View file

@ -52,7 +52,7 @@ class MCPServer(BaseModel):
env: Optional[Dict[str, str]] = None
access_groups: Optional[List[str]] = None
allow_all_keys: bool = False
available_on_public_internet: bool = False
available_on_public_internet: bool = True
updated_at: Optional[datetime] = None
model_config = ConfigDict(arbitrary_types_allowed=True)

View file

@ -300,7 +300,7 @@ model LiteLLM_MCPServerTable {
token_url String?
registration_url String?
allow_all_keys Boolean @default(false)
available_on_public_internet Boolean @default(false)
available_on_public_internet Boolean @default(true)
}
// Generate Tokens for Proxy

View file

@ -1,202 +0,0 @@
Apache License
Version 2.0, January 2004
http://www.apache.org/licenses/
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
1. Definitions.
"License" shall mean the terms and conditions for use, reproduction,
and distribution as defined by Sections 1 through 9 of this document.
"Licensor" shall mean the copyright owner or entity authorized by
the copyright owner that is granting the License.
"Legal Entity" shall mean the union of the acting entity and all
other entities that control, are controlled by, or are under common
control with that entity. For the purposes of this definition,
"control" means (i) the power, direct or indirect, to cause the
direction or management of such entity, whether by contract or
otherwise, or (ii) ownership of fifty percent (50%) or more of the
outstanding shares, or (iii) beneficial ownership of such entity.
"You" (or "Your") shall mean an individual or Legal Entity
exercising permissions granted by this License.
"Source" form shall mean the preferred form for making modifications,
including but not limited to software source code, documentation
source, and configuration files.
"Object" form shall mean any form resulting from mechanical
transformation or translation of a Source form, including but
not limited to compiled object code, generated documentation,
and conversions to other media types.
"Work" shall mean the work of authorship, whether in Source or
Object form, made available under the License, as indicated by a
copyright notice that is included in or attached to the work
(an example is provided in the Appendix below).
"Derivative Works" shall mean any work, whether in Source or Object
form, that is based on (or derived from) the Work and for which the
editorial revisions, annotations, elaborations, or other modifications
represent, as a whole, an original work of authorship. For the purposes
of this License, Derivative Works shall not include works that remain
separable from, or merely link (or bind by name) to the interfaces of,
the Work and Derivative Works thereof.
"Contribution" shall mean any work of authorship, including
the original version of the Work and any modifications or additions
to that Work or Derivative Works thereof, that is intentionally
submitted to Licensor for inclusion in the Work by the copyright owner
or by an individual or Legal Entity authorized to submit on behalf of
the copyright owner. For the purposes of this definition, "submitted"
means any form of electronic, verbal, or written communication sent
to the Licensor or its representatives, including but not limited to
communication on electronic mailing lists, source code control systems,
and issue tracking systems that are managed by, or on behalf of, the
Licensor for the purpose of discussing and improving the Work, but
excluding communication that is conspicuously marked or otherwise
designated in writing by the copyright owner as "Not a Contribution."
"Contributor" shall mean Licensor and any individual or Legal Entity
on behalf of whom a Contribution has been received by Licensor and
subsequently incorporated within the Work.
2. Grant of Copyright License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
copyright license to reproduce, prepare Derivative Works of,
publicly display, publicly perform, sublicense, and distribute the
Work and such Derivative Works in Source or Object form.
3. Grant of Patent License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
(except as stated in this section) patent license to make, have made,
use, offer to sell, sell, import, and otherwise transfer the Work,
where such license applies only to those patent claims licensable
by such Contributor that are necessarily infringed by their
Contribution(s) alone or by combination of their Contribution(s)
with the Work to which such Contribution(s) was submitted. If You
institute patent litigation against any entity (including a
cross-claim or counterclaim in a lawsuit) alleging that the Work
or a Contribution incorporated within the Work constitutes direct
or contributory patent infringement, then any patent licenses
granted to You under this License for that Work shall terminate
as of the date such litigation is filed.
4. Redistribution. You may reproduce and distribute copies of the
Work or Derivative Works thereof in any medium, with or without
modifications, and in Source or Object form, provided that You
meet the following conditions:
(a) You must give any other recipients of the Work or
Derivative Works a copy of this License; and
(b) You must cause any modified files to carry prominent notices
stating that You changed the files; and
(c) You must retain, in the Source form of any Derivative Works
that You distribute, all copyright, patent, trademark, and
attribution notices from the Source form of the Work,
excluding those notices that do not pertain to any part of
the Derivative Works; and
(d) If the Work includes a "NOTICE" text file as part of its
distribution, then any Derivative Works that You distribute must
include a readable copy of the attribution notices contained
within such NOTICE file, excluding those notices that do not
pertain to any part of the Derivative Works, in at least one
of the following places: within a NOTICE text file distributed
as part of the Derivative Works; within the Source form or
documentation, if provided along with the Derivative Works; or,
within a display generated by the Derivative Works, if and
wherever such third-party notices normally appear. The contents
of the NOTICE file are for informational purposes only and
do not modify the License. You may add Your own attribution
notices within Derivative Works that You distribute, alongside
or as an addendum to the NOTICE text from the Work, provided
that such additional attribution notices cannot be construed
as modifying the License.
You may add Your own copyright statement to Your modifications and
may provide additional or different license terms and conditions
for use, reproduction, or distribution of Your modifications, or
for any such Derivative Works as a whole, provided Your use,
reproduction, and distribution of the Work otherwise complies with
the conditions stated in this License.
5. Submission of Contributions. Unless You explicitly state otherwise,
any Contribution intentionally submitted for inclusion in the Work
by You to the Licensor shall be under the terms and conditions of
this License, without any additional terms or conditions.
Notwithstanding the above, nothing herein shall supersede or modify
the terms of any separate license agreement you may have executed
with Licensor regarding such Contributions.
6. Trademarks. This License does not grant permission to use the trade
names, trademarks, service marks, or product names of the Licensor,
except as required for reasonable and customary use in describing the
origin of the Work and reproducing the content of the NOTICE file.
7. Disclaimer of Warranty. Unless required by applicable law or
agreed to in writing, Licensor provides the Work (and each
Contributor provides its Contributions) on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
implied, including, without limitation, any warranties or conditions
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
PARTICULAR PURPOSE. You are solely responsible for determining the
appropriateness of using or redistributing the Work and assume any
risks associated with Your exercise of permissions under this License.
8. Limitation of Liability. In no event and under no legal theory,
whether in tort (including negligence), contract, or otherwise,
unless required by applicable law (such as deliberate and grossly
negligent acts) or agreed to in writing, shall any Contributor be
liable to You for damages, including any direct, indirect, special,
incidental, or consequential damages of any character arising as a
result of this License or out of the use or inability to use the
Work (including but not limited to damages for loss of goodwill,
work stoppage, computer failure or malfunction, or any and all
other commercial damages or losses), even if such Contributor
has been advised of the possibility of such damages.
9. Accepting Warranty or Additional Liability. While redistributing
the Work or Derivative Works thereof, You may choose to offer,
and charge a fee for, acceptance of support, warranty, indemnity,
or other liability obligations and/or rights consistent with this
License. However, in accepting such obligations, You may act only
on Your own behalf and on Your sole responsibility, not on behalf
of any other Contributor, and only if You agree to indemnify,
defend, and hold each Contributor harmless for any liability
incurred by, or claims asserted against, such Contributor by reason
of your accepting any such warranty or additional liability.
END OF TERMS AND CONDITIONS
APPENDIX: How to apply the Apache License to your work.
To apply the Apache License to your work, attach the following
boilerplate notice, with the fields enclosed by brackets "[]"
replaced with your own identifying information. (Don't include
the brackets!) The text should be enclosed in the appropriate
comment syntax for the file format. We also recommend that a
file or class name and description of purpose be included on the
same "printed page" as the copyright notice for easier
identification within third-party archives.
Copyright [yyyy] [name of copyright owner]
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.

View file

@ -1,7 +1,6 @@
---
name: slack-gif-creator
description: Knowledge and utilities for creating animated GIFs optimized for Slack. Provides constraints, validation tools, and animation concepts. Use when users request animated GIFs for Slack like "make me a GIF of X doing Y for Slack."
license: Complete terms in LICENSE.txt
---
# Slack GIF Creator

View file

@ -3,6 +3,7 @@ import os
import sys
import pytest
from typing import List, Any, cast
from unittest.mock import AsyncMock, patch
sys.path.insert(0, os.path.abspath("../../.."))
@ -254,6 +255,76 @@ async def test_aresponses_api_with_mcp_mock_integration():
print(f"Other tools parsed: {len(other_parsed)}")
@pytest.mark.asyncio
async def test_aresponses_api_with_mcp_passes_mcp_server_auth_headers_to_process_tools():
"""
Test that MCP auth headers from secret_fields (e.g. x-mcp-linear_config-authorization)
are passed to _process_mcp_tools_without_openai_transform when using the responses API.
"""
from litellm.responses.main import aresponses_api_with_mcp
captured_process_kwargs = {}
async def mock_process(**kwargs):
captured_process_kwargs.update(kwargs)
return ([], {})
mock_response = ResponsesAPIResponse(
**{
"id": "resp_test",
"object": "response",
"created_at": 1234567890,
"status": "completed",
"error": None,
"incomplete_details": None,
"instructions": None,
"max_output_tokens": None,
"model": "gpt-4o",
"output": [{"type": "message", "id": "msg_1", "status": "completed", "role": "assistant", "content": []}],
"parallel_tool_calls": True,
"previous_response_id": None,
"reasoning": {"effort": None, "summary": None},
"store": True,
"temperature": 1.0,
"text": {"format": {"type": "text"}},
"tool_choice": "auto",
"tools": [],
"top_p": 1.0,
"truncation": "disabled",
"usage": {"input_tokens": 1, "output_tokens": 1, "total_tokens": 2},
"user": None,
"metadata": {},
}
)
mcp_tools = [{"type": "mcp", "server_url": "litellm_proxy"}]
secret_fields = {
"raw_headers": {"x-mcp-linear_config-authorization": "Bearer linear-token"},
}
with patch.object(
LiteLLM_Proxy_MCP_Handler,
"_process_mcp_tools_without_openai_transform",
mock_process,
), patch(
"litellm.responses.main.aresponses",
new_callable=AsyncMock,
return_value=mock_response,
):
await aresponses_api_with_mcp(
input=[{"role": "user", "type": "message", "content": "hi"}],
model="gpt-4o",
tools=mcp_tools,
secret_fields=secret_fields,
)
assert "mcp_server_auth_headers" in captured_process_kwargs
mcp_server_auth_headers = captured_process_kwargs["mcp_server_auth_headers"]
assert mcp_server_auth_headers is not None
assert "linear_config" in mcp_server_auth_headers
assert mcp_server_auth_headers["linear_config"]["Authorization"] == "Bearer linear-token"
@pytest.mark.asyncio
async def test_mcp_allowed_tools_filtering():
"""

View file

@ -659,9 +659,9 @@ async def test_list_tools_rest_api_server_not_found():
mock_manager.get_allowed_mcp_servers = AsyncMock(
return_value=["non_existent_server_id"]
)
# Mock filter_server_ids_by_ip to return input unchanged (no IP filtering in test)
mock_manager.filter_server_ids_by_ip = MagicMock(
side_effect=lambda server_ids, client_ip: server_ids
# Mock filter_server_ids_by_ip_with_info to return input unchanged (no IP filtering in test)
mock_manager.filter_server_ids_by_ip_with_info = MagicMock(
side_effect=lambda server_ids, client_ip: (server_ids, 0)
)
# Return None when trying to get the server (server doesn't exist)
mock_manager.get_mcp_server_by_id = MagicMock(return_value=None)
@ -732,9 +732,9 @@ async def test_list_tools_rest_api_success():
return_value=["test-server-123"]
)
mock_manager.get_mcp_server_by_id = MagicMock(return_value=mock_server)
# Mock filter_server_ids_by_ip to return input unchanged (no IP filtering in test)
mock_manager.filter_server_ids_by_ip = MagicMock(
side_effect=lambda server_ids, client_ip: server_ids
# Mock filter_server_ids_by_ip_with_info to return input unchanged (no IP filtering in test)
mock_manager.filter_server_ids_by_ip_with_info = MagicMock(
side_effect=lambda server_ids, client_ip: (server_ids, 0)
)
# Mock the _get_tools_for_single_server function
@ -814,9 +814,9 @@ async def test_get_tools_from_mcp_servers():
)
mock_manager.get_mcp_server_by_id = lambda server_id: mock_server_1 if server_id == "server1_id" else mock_server_2
mock_manager._get_tools_from_server = AsyncMock(return_value=[mock_tool_1])
# Mock filter_server_ids_by_ip to return input unchanged (no IP filtering in test)
mock_manager.filter_server_ids_by_ip = MagicMock(
side_effect=lambda server_ids, client_ip: server_ids
# Mock filter_server_ids_by_ip_with_info to return input unchanged (no IP filtering in test)
mock_manager.filter_server_ids_by_ip_with_info = MagicMock(
side_effect=lambda server_ids, client_ip: (server_ids, 0)
)
with patch(
@ -853,9 +853,9 @@ async def test_get_tools_from_mcp_servers():
mock_manager_2._get_tools_from_server = AsyncMock(
side_effect=mock_get_tools_side_effect
)
# Mock filter_server_ids_by_ip to return input unchanged (no IP filtering in test)
mock_manager_2.filter_server_ids_by_ip = MagicMock(
side_effect=lambda server_ids, client_ip: server_ids
# Mock filter_server_ids_by_ip_with_info to return input unchanged (no IP filtering in test)
mock_manager_2.filter_server_ids_by_ip_with_info = MagicMock(
side_effect=lambda server_ids, client_ip: (server_ids, 0)
)
with patch(
@ -881,9 +881,9 @@ async def test_get_tools_from_mcp_servers():
)
mock_manager.get_mcp_server_by_id = lambda server_id: mock_server_1 if server_id == "server1_id" else (mock_server_2 if server_id == "server2_id" else mock_server_3)
mock_manager._get_tools_from_server = AsyncMock(return_value=[mock_tool_1])
# Mock filter_server_ids_by_ip to return input unchanged (no IP filtering in test)
mock_manager.filter_server_ids_by_ip = MagicMock(
side_effect=lambda server_ids, client_ip: server_ids
# Mock filter_server_ids_by_ip_with_info to return input unchanged (no IP filtering in test)
mock_manager.filter_server_ids_by_ip_with_info = MagicMock(
side_effect=lambda server_ids, client_ip: (server_ids, 0)
)
with patch(
@ -1817,9 +1817,9 @@ async def test_list_tool_rest_api_with_server_specific_auth():
mock_server.mcp_info = {"server_name": "zapier"}
mock_manager.get_mcp_server_by_id.return_value = mock_server
# Mock filter_server_ids_by_ip to return input unchanged (no IP filtering in test)
mock_manager.filter_server_ids_by_ip = MagicMock(
side_effect=lambda server_ids, client_ip: server_ids
# Mock filter_server_ids_by_ip_with_info to return input unchanged (no IP filtering in test)
mock_manager.filter_server_ids_by_ip_with_info = MagicMock(
side_effect=lambda server_ids, client_ip: (server_ids, 0)
)
mock_user_api_key_dict = UserAPIKeyAuth(
@ -1911,9 +1911,9 @@ async def test_list_tool_rest_api_with_default_auth():
mock_server.mcp_info = {"server_name": "unknown_server"}
mock_manager.get_mcp_server_by_id.return_value = mock_server
# Mock filter_server_ids_by_ip to return input unchanged (no IP filtering in test)
mock_manager.filter_server_ids_by_ip = MagicMock(
side_effect=lambda server_ids, client_ip: server_ids
# Mock filter_server_ids_by_ip_with_info to return input unchanged (no IP filtering in test)
mock_manager.filter_server_ids_by_ip_with_info = MagicMock(
side_effect=lambda server_ids, client_ip: (server_ids, 0)
)
mock_user_api_key_dict = UserAPIKeyAuth(
@ -2021,9 +2021,9 @@ async def test_list_tool_rest_api_all_servers_with_auth():
server_id
)
)
# Mock filter_server_ids_by_ip to return input unchanged (no IP filtering in test)
mock_manager.filter_server_ids_by_ip = MagicMock(
side_effect=lambda server_ids, client_ip: server_ids
# Mock filter_server_ids_by_ip_with_info to return input unchanged (no IP filtering in test)
mock_manager.filter_server_ids_by_ip_with_info = MagicMock(
side_effect=lambda server_ids, client_ip: (server_ids, 0)
)
mock_user_api_key_dict = UserAPIKeyAuth(
@ -2154,9 +2154,9 @@ async def test_filter_tools_by_allowed_tools_integration():
return_value=["test-server-123"]
)
mock_manager.get_mcp_server_by_id = MagicMock(return_value=mock_server)
# Mock filter_server_ids_by_ip to return input unchanged (no IP filtering in test)
mock_manager.filter_server_ids_by_ip = MagicMock(
side_effect=lambda server_ids, client_ip: server_ids
# Mock filter_server_ids_by_ip_with_info to return input unchanged (no IP filtering in test)
mock_manager.filter_server_ids_by_ip_with_info = MagicMock(
side_effect=lambda server_ids, client_ip: (server_ids, 0)
)
# Mock the _get_tools_from_server method to return all tools
@ -2268,9 +2268,9 @@ async def test_filter_tools_by_disallowed_tools_integration():
return_value=["test-server-456"]
)
mock_manager.get_mcp_server_by_id = MagicMock(return_value=mock_server)
# Mock filter_server_ids_by_ip to return input unchanged (no IP filtering in test)
mock_manager.filter_server_ids_by_ip = MagicMock(
side_effect=lambda server_ids, client_ip: server_ids
# Mock filter_server_ids_by_ip_with_info to return input unchanged (no IP filtering in test)
mock_manager.filter_server_ids_by_ip_with_info = MagicMock(
side_effect=lambda server_ids, client_ip: (server_ids, 0)
)
# Mock the _get_tools_from_server method to return all tools
mock_manager._get_tools_from_server = AsyncMock(return_value=mock_tools)
@ -2368,9 +2368,9 @@ async def test_filter_tools_no_restrictions_integration():
return_value=["test-server-000"]
)
mock_manager.get_mcp_server_by_id = MagicMock(return_value=mock_server)
# Mock filter_server_ids_by_ip to return input unchanged (no IP filtering in test)
mock_manager.filter_server_ids_by_ip = MagicMock(
side_effect=lambda server_ids, client_ip: server_ids
# Mock filter_server_ids_by_ip_with_info to return input unchanged (no IP filtering in test)
mock_manager.filter_server_ids_by_ip_with_info = MagicMock(
side_effect=lambda server_ids, client_ip: (server_ids, 0)
)
# Mock the _get_tools_from_server method to return all tools

View file

@ -2,7 +2,8 @@ import asyncio
import json
import os
import sys
from typing import Any, Dict, List, Optional
from datetime import datetime
from typing import Any, Dict, List, Optional, Union
from unittest.mock import Mock
import pytest
@ -1486,12 +1487,46 @@ class MockPrismaClientDB:
mock_key_data,
):
self.db = MockDb(mock_team_data, mock_key_data)
async def get_data(
self,
token: Optional[Union[str, list]] = None,
user_id: Optional[str] = None,
user_id_list: Optional[list] = None,
team_id: Optional[str] = None,
team_id_list: Optional[list] = None,
key_val: Optional[dict] = None,
table_name: Optional[str] = None,
query_type: str = "find_unique",
expires: Optional[datetime] = None,
reset_at: Optional[datetime] = None,
offset: Optional[int] = None,
limit: Optional[int] = None,
):
"""Mock get_data method to return user info for admin"""
from litellm.proxy._types import LiteLLM_UserTable
# Return a proper LiteLLM_UserTable object when querying by user_id
if user_id:
return LiteLLM_UserTable(
user_id=user_id,
user_role="proxy_admin",
spend=0.0,
max_budget=None,
)
return None
@pytest.mark.asyncio
async def test_get_user_info_for_proxy_admin(mock_team_data, mock_key_data):
# Patch the prisma_client import
from litellm.proxy._types import UserInfoResponse
from litellm.proxy._types import UserAPIKeyAuth, UserInfoResponse
# Create a mock user_api_key_dict for admin user
mock_user_api_key_dict = UserAPIKeyAuth(
user_id="admin_user_123",
user_role="proxy_admin",
)
with patch(
"litellm.proxy.proxy_server.prisma_client",
@ -1502,11 +1537,18 @@ async def test_get_user_info_for_proxy_admin(mock_team_data, mock_key_data):
)
# Execute the function
result = await _get_user_info_for_proxy_admin()
result = await _get_user_info_for_proxy_admin(
user_api_key_dict=mock_user_api_key_dict
)
# Verify the result structure
assert isinstance(result, UserInfoResponse)
assert len(result.keys) == 2
# Verify admin's user_id is populated
assert result.user_id == "admin_user_123"
# Verify admin's user_info is populated
assert result.user_info is not None
assert result.user_info["user_id"] == "admin_user_123"
def test_custom_openid_response():

View file

@ -0,0 +1,158 @@
import asyncio
import os
import sys
import warnings
import pytest
sys.path.insert(
0, os.path.abspath("../../..")
) # Adds the parent directory to the system path
from litellm.caching.llm_caching_handler import LLMClientCache
class MockAsyncClient:
"""Mock async HTTP client with an async close method."""
def __init__(self):
self.closed = False
async def close(self):
self.closed = True
class MockSyncClient:
"""Mock sync HTTP client with a sync close method."""
def __init__(self):
self.closed = False
def close(self):
self.closed = True
@pytest.mark.asyncio
async def test_remove_key_no_unawaited_coroutine_warning():
"""
Test that evicting an async client from LLMClientCache does not produce
'coroutine was never awaited' warnings.
Regression test for https://github.com/BerriAI/litellm/issues/22128
"""
cache = LLMClientCache(max_size_in_memory=2)
mock_client = MockAsyncClient()
cache.cache_dict["test-key"] = mock_client
cache.ttl_dict["test-key"] = 0 # expired
with warnings.catch_warnings(record=True) as caught_warnings:
warnings.simplefilter("always")
cache._remove_key("test-key")
# Let the event loop process the close task
await asyncio.sleep(0.1)
coroutine_warnings = [
w for w in caught_warnings if "coroutine" in str(w.message).lower()
]
assert (
len(coroutine_warnings) == 0
), f"Got unawaited coroutine warnings: {coroutine_warnings}"
@pytest.mark.asyncio
async def test_remove_key_closes_async_client():
"""
Test that evicting an async client from the cache properly closes it.
"""
cache = LLMClientCache(max_size_in_memory=2)
mock_client = MockAsyncClient()
cache.cache_dict["test-key"] = mock_client
cache.ttl_dict["test-key"] = 0
cache._remove_key("test-key")
# Let the event loop process the close task
await asyncio.sleep(0.1)
assert mock_client.closed is True
assert "test-key" not in cache.cache_dict
assert "test-key" not in cache.ttl_dict
def test_remove_key_closes_sync_client():
"""
Test that evicting a sync client from the cache properly closes it.
"""
cache = LLMClientCache(max_size_in_memory=2)
mock_client = MockSyncClient()
cache.cache_dict["test-key"] = mock_client
cache.ttl_dict["test-key"] = 0
cache._remove_key("test-key")
assert mock_client.closed is True
assert "test-key" not in cache.cache_dict
@pytest.mark.asyncio
async def test_eviction_closes_async_clients():
"""
Test that cache eviction (when cache is full) properly closes async clients
without producing warnings.
"""
cache = LLMClientCache(max_size_in_memory=2, default_ttl=1)
clients = []
for i in range(2):
client = MockAsyncClient()
clients.append(client)
cache.set_cache(f"key-{i}", client)
with warnings.catch_warnings(record=True) as caught_warnings:
warnings.simplefilter("always")
# This should trigger eviction of one of the existing entries
cache.set_cache("key-new", "new-value")
await asyncio.sleep(0.1)
coroutine_warnings = [
w for w in caught_warnings if "coroutine" in str(w.message).lower()
]
assert (
len(coroutine_warnings) == 0
), f"Got unawaited coroutine warnings: {coroutine_warnings}"
def test_remove_key_no_event_loop():
"""
Test that _remove_key doesn't raise when there's no running event loop
(falls through to the RuntimeError except branch).
"""
cache = LLMClientCache(max_size_in_memory=2)
mock_client = MockAsyncClient()
cache.cache_dict["test-key"] = mock_client
cache.ttl_dict["test-key"] = 0
# Should not raise even though there's no running event loop
cache._remove_key("test-key")
assert "test-key" not in cache.cache_dict
@pytest.mark.asyncio
async def test_background_tasks_cleaned_up_after_completion():
"""
Test that completed close tasks are removed from the _background_tasks set.
"""
cache = LLMClientCache(max_size_in_memory=2)
mock_client = MockAsyncClient()
cache.cache_dict["test-key"] = mock_client
cache.ttl_dict["test-key"] = 0
cache._remove_key("test-key")
# Let the task complete
await asyncio.sleep(0.1)
assert len(cache._background_tasks) == 0

View file

@ -379,7 +379,8 @@ async def test_realtime_guardrail_blocks_prompt_injection():
"""
Test that when a transcription event containing prompt injection arrives from the
backend, a registered guardrail blocks it sending a warning to the client
and NOT sending response.create to the backend.
and voicing the guardrail violation message via response.cancel +
conversation.item.create + response.create.
"""
import litellm
from litellm.integrations.custom_guardrail import CustomGuardrail
@ -430,19 +431,36 @@ async def test_realtime_guardrail_blocks_prompt_injection():
streaming = RealTimeStreaming(client_ws, backend_ws, logging_obj)
await streaming.backend_to_client_send_messages()
# ASSERT 1: no response.create was sent to backend (injection blocked).
# ASSERT 1: the guardrail blocked the normal auto-response and instead
# injected a conversation.item.create + response.create to voice the
# violation message. There should be exactly ONE response.create (the
# guardrail-triggered one), preceded by a response.cancel and a
# conversation.item.create carrying the violation text.
sent_to_backend = [
json.loads(c.args[0])
for c in backend_ws.send.call_args_list
if c.args
]
response_creates = [
e for e in sent_to_backend
if e.get("type") == "response.create"
response_cancels = [
e for e in sent_to_backend if e.get("type") == "response.cancel"
]
assert len(response_creates) == 0, (
f"Guardrail should prevent response.create for injected content, "
f"but got: {response_creates}"
assert len(response_cancels) == 1, (
f"Guardrail should send response.cancel, got: {response_cancels}"
)
guardrail_items = [
e for e in sent_to_backend
if e.get("type") == "conversation.item.create"
]
assert len(guardrail_items) == 1, (
f"Guardrail should inject a conversation.item.create with violation message, "
f"got: {guardrail_items}"
)
response_creates = [
e for e in sent_to_backend if e.get("type") == "response.create"
]
assert len(response_creates) == 1, (
f"Guardrail should send exactly one response.create to voice the violation, "
f"got: {response_creates}"
)
# ASSERT 2: error event was sent directly to the client WebSocket
@ -595,14 +613,26 @@ async def test_realtime_text_input_guardrail_blocks_and_returns_error():
assert len(error_events) == 1, f"Expected one error event, got: {sent_texts}"
assert error_events[0]["error"]["type"] == "guardrail_violation"
# ASSERT: blocked item was NOT forwarded to the backend
# ASSERT: the original blocked item was NOT forwarded to the backend.
# The guardrail handler injects its own conversation.item.create with
# the violation message — only that one should be present, not the
# original user message.
sent_to_backend = [c.args[0] for c in backend_ws.send.call_args_list if c.args]
forwarded_items = [
json.loads(m) for m in sent_to_backend
if isinstance(m, str) and json.loads(m).get("type") == "conversation.item.create"
]
assert len(forwarded_items) == 0, (
f"Blocked item should not be forwarded to backend, got: {forwarded_items}"
# Filter out guardrail-injected items (contain "Say exactly the following message")
original_items = [
item for item in forwarded_items
if not any(
"Say exactly the following message" in c.get("text", "")
for c in item.get("item", {}).get("content", [])
if isinstance(c, dict)
)
]
assert len(original_items) == 0, (
f"Blocked item should not be forwarded to backend, got: {original_items}"
)
litellm.callbacks = [] # cleanup

View file

@ -39,14 +39,14 @@ def test_anthropic_experimental_pass_through_messages_handler():
def test_anthropic_experimental_pass_through_messages_handler_dynamic_api_key_and_api_base_and_custom_values():
"""
Test that api key, api base, and extra kwargs are forwarded to litellm.responses for Azure models.
Azure models are routed directly to the Responses API.
Test that api key, api base, and extra kwargs are forwarded to litellm.completion for Azure models.
Azure models are routed through chat/completions (not the Responses API).
"""
from litellm.llms.anthropic.experimental_pass_through.messages.handler import (
anthropic_messages_handler,
)
with patch("litellm.responses", return_value="test-response") as mock_responses:
with patch("litellm.completion", return_value=MagicMock()) as mock_completion:
try:
anthropic_messages_handler(
max_tokens=100,
@ -58,10 +58,10 @@ def test_anthropic_experimental_pass_through_messages_handler_dynamic_api_key_an
)
except Exception as e:
print(f"Error: {e}")
mock_responses.assert_called_once()
assert mock_responses.call_args.kwargs["api_key"] == "test-api-key"
assert mock_responses.call_args.kwargs["api_base"] == "test-api-base"
assert mock_responses.call_args.kwargs["custom_key"] == "custom_value"
mock_completion.assert_called_once()
assert mock_completion.call_args.kwargs["api_key"] == "test-api-key"
assert mock_completion.call_args.kwargs["api_base"] == "test-api-base"
assert mock_completion.call_args.kwargs["custom_key"] == "custom_value"
def test_anthropic_experimental_pass_through_messages_handler_custom_llm_provider():

View file

@ -3493,3 +3493,262 @@ class TestBedrockMinThinkingBudgetTokens:
drop_params=False,
)
assert "thinking" not in result or result.get("thinking") is None
def test_transform_response_with_both_json_tool_call_and_real_tool():
"""
When Bedrock returns BOTH json_tool_call AND a real tool (get_weather),
only the real tool should remain in tool_calls. The json_tool_call should be filtered out.
Fixes https://github.com/BerriAI/litellm/issues/18381
"""
from litellm.llms.bedrock.chat.converse_transformation import AmazonConverseConfig
from litellm.types.utils import ModelResponse
response_json = {
"metrics": {"latencyMs": 200},
"output": {
"message": {
"role": "assistant",
"content": [
{
"toolUse": {
"toolUseId": "tooluse_json_001",
"name": "json_tool_call",
"input": {
"Current_Temperature": 62,
"Weather_Explanation": "Mild and cool.",
},
}
},
{
"toolUse": {
"toolUseId": "tooluse_weather_001",
"name": "get_weather",
"input": {
"location": "San Francisco, CA",
"unit": "fahrenheit",
},
}
},
],
}
},
"stopReason": "tool_use",
"usage": {
"inputTokens": 100,
"outputTokens": 50,
"totalTokens": 150,
"cacheReadInputTokenCount": 0,
"cacheReadInputTokens": 0,
"cacheWriteInputTokenCount": 0,
"cacheWriteInputTokens": 0,
},
}
class MockResponse:
def json(self):
return response_json
@property
def text(self):
return json.dumps(response_json)
config = AmazonConverseConfig()
model_response = ModelResponse()
optional_params = {"json_mode": True}
result = config._transform_response(
model="bedrock/anthropic.claude-3-5-sonnet-20240620-v1:0",
response=MockResponse(),
model_response=model_response,
stream=False,
logging_obj=None,
optional_params=optional_params,
api_key=None,
data=None,
messages=[],
encoding=None,
)
# Only real tool should remain
assert result.choices[0].message.tool_calls is not None
assert len(result.choices[0].message.tool_calls) == 1
assert result.choices[0].message.tool_calls[0].function.name == "get_weather"
assert (
result.choices[0].message.tool_calls[0].function.arguments
== '{"location": "San Francisco, CA", "unit": "fahrenheit"}'
)
# json_tool_call content should be preserved as message text
content = result.choices[0].message.content
assert content is not None
parsed = json.loads(content)
assert parsed["Current_Temperature"] == 62
assert parsed["Weather_Explanation"] == "Mild and cool."
def test_transform_response_does_not_mutate_optional_params():
"""
Verify that optional_params still contains json_mode after _transform_response.
Previously, .pop() was used which mutated the caller's dict.
"""
from litellm.llms.bedrock.chat.converse_transformation import AmazonConverseConfig
from litellm.types.utils import ModelResponse
response_json = {
"metrics": {"latencyMs": 50},
"output": {
"message": {
"role": "assistant",
"content": [
{
"toolUse": {
"toolUseId": "tooluse_001",
"name": "json_tool_call",
"input": {"result": "ok"},
}
}
],
}
},
"stopReason": "tool_use",
"usage": {
"inputTokens": 10,
"outputTokens": 5,
"totalTokens": 15,
"cacheReadInputTokenCount": 0,
"cacheReadInputTokens": 0,
"cacheWriteInputTokenCount": 0,
"cacheWriteInputTokens": 0,
},
}
class MockResponse:
def json(self):
return response_json
@property
def text(self):
return json.dumps(response_json)
config = AmazonConverseConfig()
model_response = ModelResponse()
optional_params = {"json_mode": True, "other_key": "value"}
config._transform_response(
model="bedrock/anthropic.claude-3-5-sonnet-20240620-v1:0",
response=MockResponse(),
model_response=model_response,
stream=False,
logging_obj=None,
optional_params=optional_params,
api_key=None,
data=None,
messages=[],
encoding=None,
)
# json_mode should still be in optional_params (not popped)
assert "json_mode" in optional_params
assert optional_params["json_mode"] is True
assert optional_params["other_key"] == "value"
def test_streaming_filters_json_tool_call_with_real_tools():
"""
Simulate streaming chunks where both json_tool_call and a real tool arrive.
Verify json_tool_call chunks are converted to text content while real tool
chunks pass through normally.
"""
from litellm.llms.bedrock.chat.invoke_handler import AWSEventStreamDecoder
from litellm.types.llms.bedrock import (
ContentBlockDeltaEvent,
ContentBlockStartEvent,
)
decoder = AWSEventStreamDecoder(model="test-model", json_mode=True)
# Chunk 1: json_tool_call start
json_start = ContentBlockStartEvent(
toolUse={
"toolUseId": "tooluse_json_001",
"name": "json_tool_call",
}
)
tool_use_1, _, _ = decoder._handle_converse_start_event(json_start)
# json_tool_call start should be suppressed (return None tool_use)
assert tool_use_1 is None
# tool_calls_index should NOT have been incremented
assert decoder.tool_calls_index is None
# Chunk 2: json_tool_call delta — should become text, not tool_use
json_delta = ContentBlockDeltaEvent(toolUse={"input": '{"temp": 62}'})
text_2, tool_use_2, _, _, _ = decoder._handle_converse_delta_event(
json_delta, index=0
)
assert text_2 == '{"temp": 62}'
assert tool_use_2 is None
# Chunk 3: json_tool_call stop
stop_tool = decoder._handle_converse_stop_event(index=0)
assert stop_tool is None
# _current_tool_name should be reset
assert decoder._current_tool_name is None
# Chunk 4: real tool start
real_start = ContentBlockStartEvent(
toolUse={
"toolUseId": "tooluse_weather_001",
"name": "get_weather",
}
)
tool_use_4, _, _ = decoder._handle_converse_start_event(real_start)
assert tool_use_4 is not None
assert tool_use_4["function"]["name"] == "get_weather"
assert decoder.tool_calls_index == 0
# Chunk 5: real tool delta
real_delta = ContentBlockDeltaEvent(
toolUse={"input": '{"location": "SF"}'}
)
text_5, tool_use_5, _, _, _ = decoder._handle_converse_delta_event(
real_delta, index=1
)
assert text_5 == ""
assert tool_use_5 is not None
assert tool_use_5["function"]["arguments"] == '{"location": "SF"}'
def test_streaming_without_json_mode_passes_all_tools():
"""
Verify backward compatibility: when json_mode=False, all tools
(including json_tool_call if present) pass through unchanged.
"""
from litellm.llms.bedrock.chat.invoke_handler import AWSEventStreamDecoder
from litellm.types.llms.bedrock import (
ContentBlockDeltaEvent,
ContentBlockStartEvent,
)
decoder = AWSEventStreamDecoder(model="test-model", json_mode=False)
# json_tool_call start — should pass through when json_mode=False
json_start = ContentBlockStartEvent(
toolUse={
"toolUseId": "tooluse_json_001",
"name": "json_tool_call",
}
)
tool_use, _, _ = decoder._handle_converse_start_event(json_start)
assert tool_use is not None
assert tool_use["function"]["name"] == "json_tool_call"
assert decoder.tool_calls_index == 0
# json_tool_call delta — should be a tool_use, not text
json_delta = ContentBlockDeltaEvent(toolUse={"input": '{"data": 1}'})
text, tool_use_delta, _, _, _ = decoder._handle_converse_delta_event(
json_delta, index=0
)
assert text == ""
assert tool_use_delta is not None
assert tool_use_delta["function"]["arguments"] == '{"data": 1}'

View file

@ -0,0 +1,212 @@
"""
Unit tests for extra_headers propagation in OpenAI image generation.
Verifies that extra_headers passed to litellm.image_generation() /
litellm.aimage_generation() are forwarded to the OpenAI API client as
extra_headers in the images.generate() call.
"""
import os
import sys
from unittest.mock import MagicMock, AsyncMock, patch
import pytest
sys.path.insert(0, os.path.abspath("../../../../.."))
from litellm.llms.openai.openai import OpenAIChatCompletion
@pytest.fixture
def openai_chat_completions():
return OpenAIChatCompletion()
@pytest.fixture
def mock_logging_obj():
logging_obj = MagicMock()
logging_obj.pre_call = MagicMock()
logging_obj.post_call = MagicMock()
return logging_obj
class TestImageGenerationExtraHeaders:
"""Test that extra_headers are properly injected into OpenAI image generation calls."""
def test_sync_image_generation_with_headers(
self, openai_chat_completions, mock_logging_obj
):
"""Sync image_generation should pass headers as extra_headers to images.generate()."""
mock_image_data = MagicMock()
mock_image_data.model_dump.return_value = {
"created": 1700000000,
"data": [{"url": "https://example.com/image.png"}],
}
mock_openai_client = MagicMock()
mock_openai_client.images.generate.return_value = mock_image_data
mock_openai_client.api_key = "test-key"
mock_openai_client._base_url._uri_reference = "https://api.openai.com"
test_headers = {"cf-aig-authorization": "Bearer custom-token"}
openai_chat_completions.image_generation(
model="dall-e-3",
prompt="A white cat",
timeout=60.0,
optional_params={},
logging_obj=mock_logging_obj,
api_key="test-key",
headers=test_headers,
client=mock_openai_client,
)
_, kwargs = mock_openai_client.images.generate.call_args
assert kwargs.get("extra_headers") == test_headers
def test_sync_image_generation_without_headers(
self, openai_chat_completions, mock_logging_obj
):
"""Sync image_generation without headers should not inject extra_headers."""
mock_image_data = MagicMock()
mock_image_data.model_dump.return_value = {
"created": 1700000000,
"data": [{"url": "https://example.com/image.png"}],
}
mock_openai_client = MagicMock()
mock_openai_client.images.generate.return_value = mock_image_data
mock_openai_client.api_key = "test-key"
mock_openai_client._base_url._uri_reference = "https://api.openai.com"
openai_chat_completions.image_generation(
model="dall-e-3",
prompt="A white cat",
timeout=60.0,
optional_params={},
logging_obj=mock_logging_obj,
api_key="test-key",
client=mock_openai_client,
)
_, kwargs = mock_openai_client.images.generate.call_args
assert "extra_headers" not in kwargs
@pytest.mark.asyncio
async def test_async_image_generation_with_headers(
self, openai_chat_completions, mock_logging_obj
):
"""Async aimage_generation should pass headers as extra_headers to images.generate()."""
mock_image_data = MagicMock()
mock_image_data.model_dump.return_value = {
"created": 1700000000,
"data": [{"url": "https://example.com/image.png"}],
}
mock_openai_client = MagicMock()
mock_openai_client.images.generate = AsyncMock(return_value=mock_image_data)
mock_openai_client.api_key = "test-key"
test_headers = {"cf-aig-authorization": "Bearer custom-token"}
await openai_chat_completions.aimage_generation(
prompt="A white cat",
data={"model": "dall-e-3", "prompt": "A white cat"},
model_response=MagicMock(),
timeout=60.0,
logging_obj=mock_logging_obj,
api_key="test-key",
headers=test_headers,
client=mock_openai_client,
)
_, kwargs = mock_openai_client.images.generate.call_args
assert kwargs.get("extra_headers") == test_headers
@pytest.mark.asyncio
async def test_async_image_generation_without_headers(
self, openai_chat_completions, mock_logging_obj
):
"""Async aimage_generation without headers should not inject extra_headers."""
mock_image_data = MagicMock()
mock_image_data.model_dump.return_value = {
"created": 1700000000,
"data": [{"url": "https://example.com/image.png"}],
}
mock_openai_client = MagicMock()
mock_openai_client.images.generate = AsyncMock(return_value=mock_image_data)
mock_openai_client.api_key = "test-key"
await openai_chat_completions.aimage_generation(
prompt="A white cat",
data={"model": "dall-e-3", "prompt": "A white cat"},
model_response=MagicMock(),
timeout=60.0,
logging_obj=mock_logging_obj,
api_key="test-key",
client=mock_openai_client,
)
_, kwargs = mock_openai_client.images.generate.call_args
assert "extra_headers" not in kwargs
def test_sync_image_generation_forwards_headers_to_async(
self, openai_chat_completions, mock_logging_obj
):
"""When aimg_generation=True, image_generation should forward headers to aimage_generation."""
with patch.object(
openai_chat_completions, "aimage_generation"
) as mock_aimage_gen:
mock_aimage_gen.return_value = MagicMock()
test_headers = {"x-custom-header": "value"}
openai_chat_completions.image_generation(
model="dall-e-3",
prompt="A white cat",
timeout=60.0,
optional_params={},
logging_obj=mock_logging_obj,
api_key="test-key",
aimg_generation=True,
headers=test_headers,
)
mock_aimage_gen.assert_called_once()
call_kwargs = mock_aimage_gen.call_args[1]
assert call_kwargs["headers"] == test_headers
class TestImageGenerationEntryPointHeaders:
"""Test that litellm.image_generation() passes headers through to the OpenAI provider."""
@pytest.mark.asyncio
async def test_extra_headers_reach_openai_provider(self):
"""End-to-end: extra_headers from litellm.aimage_generation() reach OpenAI images.generate()."""
import litellm
mock_image_data = MagicMock()
mock_image_data.model_dump.return_value = {
"created": 1700000000,
"data": [{"url": "https://example.com/image.png"}],
}
mock_openai_client = MagicMock()
mock_openai_client.images.generate = AsyncMock(return_value=mock_image_data)
mock_openai_client.api_key = "test-key"
mock_openai_client._base_url._uri_reference = "https://api.openai.com"
test_headers = {"cf-aig-authorization": "Bearer my-secret"}
await litellm.aimage_generation(
model="dall-e-3",
prompt="A white cat",
extra_headers=test_headers,
client=mock_openai_client,
api_key="test-key",
)
mock_openai_client.images.generate.assert_called_once()
_, kwargs = mock_openai_client.images.generate.call_args
assert kwargs.get("extra_headers") == test_headers

View file

@ -7,6 +7,7 @@ from fastapi.testclient import TestClient
from litellm.proxy._types import LitellmUserRoles, UserAPIKeyAuth
from litellm.proxy.agent_endpoints import endpoints as agent_endpoints
from litellm.proxy.agent_endpoints.endpoints import (
_check_agent_management_permission,
get_agent_daily_activity,
router,
user_api_key_auth,
@ -47,6 +48,16 @@ def _sample_agent_response(
)
def _make_app_with_role(role: LitellmUserRoles) -> TestClient:
"""Create a TestClient where the auth dependency returns the given role."""
test_app = FastAPI()
test_app.include_router(router)
test_app.dependency_overrides[user_api_key_auth] = lambda: UserAPIKeyAuth(
user_id="test-user", user_role=role
)
return TestClient(test_app)
app = FastAPI()
app.include_router(router)
app.dependency_overrides[user_api_key_auth] = lambda: UserAPIKeyAuth(
@ -258,3 +269,173 @@ async def test_get_agent_daily_activity_with_agent_names(monkeypatch):
"agent-1": {"agent_name": "First Agent"},
"agent-2": {"agent_name": "Second Agent"},
}
# ---------- RBAC enforcement tests ----------
class TestAgentRBACInternalUser:
"""Internal users should be able to read agents but not create/update/delete."""
@pytest.fixture(autouse=True)
def _setup(self, monkeypatch):
self.internal_client = _make_app_with_role(LitellmUserRoles.INTERNAL_USER)
self.mock_registry = MagicMock()
monkeypatch.setattr(agent_endpoints, "AGENT_REGISTRY", self.mock_registry)
def test_should_allow_internal_user_to_list_agents(self, monkeypatch):
self.mock_registry.get_agent_list = MagicMock(return_value=[])
resp = self.internal_client.get(
"/v1/agents", headers={"Authorization": "Bearer k"}
)
assert resp.status_code == 200
def test_should_allow_internal_user_to_get_agent_by_id(self, monkeypatch):
self.mock_registry.get_agent_by_id = MagicMock(
return_value=_sample_agent_response()
)
with patch("litellm.proxy.proxy_server.prisma_client") as mock_prisma:
resp = self.internal_client.get(
"/v1/agents/agent-123", headers={"Authorization": "Bearer k"}
)
assert resp.status_code == 200
def test_should_block_internal_user_from_creating_agent(self):
resp = self.internal_client.post(
"/v1/agents",
json=_sample_agent_config(),
headers={"Authorization": "Bearer k"},
)
assert resp.status_code == 403
assert "Only proxy admins" in resp.json()["detail"]["error"]
def test_should_block_internal_user_from_updating_agent(self):
resp = self.internal_client.put(
"/v1/agents/agent-123",
json=_sample_agent_config(),
headers={"Authorization": "Bearer k"},
)
assert resp.status_code == 403
def test_should_block_internal_user_from_patching_agent(self):
resp = self.internal_client.patch(
"/v1/agents/agent-123",
json={"agent_name": "new-name"},
headers={"Authorization": "Bearer k"},
)
assert resp.status_code == 403
def test_should_block_internal_user_from_deleting_agent(self):
resp = self.internal_client.delete(
"/v1/agents/agent-123", headers={"Authorization": "Bearer k"}
)
assert resp.status_code == 403
class TestAgentRBACInternalUserViewOnly:
"""View-only internal users should only be able to read agents."""
@pytest.fixture(autouse=True)
def _setup(self, monkeypatch):
self.viewer_client = _make_app_with_role(
LitellmUserRoles.INTERNAL_USER_VIEW_ONLY
)
self.mock_registry = MagicMock()
monkeypatch.setattr(agent_endpoints, "AGENT_REGISTRY", self.mock_registry)
def test_should_allow_view_only_user_to_list_agents(self):
self.mock_registry.get_agent_list = MagicMock(return_value=[])
resp = self.viewer_client.get(
"/v1/agents", headers={"Authorization": "Bearer k"}
)
assert resp.status_code == 200
def test_should_block_view_only_user_from_creating_agent(self):
resp = self.viewer_client.post(
"/v1/agents",
json=_sample_agent_config(),
headers={"Authorization": "Bearer k"},
)
assert resp.status_code == 403
def test_should_block_view_only_user_from_deleting_agent(self):
resp = self.viewer_client.delete(
"/v1/agents/agent-123", headers={"Authorization": "Bearer k"}
)
assert resp.status_code == 403
class TestAgentRBACProxyAdmin:
"""Proxy admins should have full CRUD access to agents."""
@pytest.fixture(autouse=True)
def _setup(self, monkeypatch):
self.admin_client = _make_app_with_role(LitellmUserRoles.PROXY_ADMIN)
self.mock_registry = MagicMock()
monkeypatch.setattr(agent_endpoints, "AGENT_REGISTRY", self.mock_registry)
def test_should_allow_admin_to_create_agent(self, monkeypatch):
with patch("litellm.proxy.proxy_server.prisma_client") as mock_prisma:
self.mock_registry.get_agent_by_name = MagicMock(return_value=None)
self.mock_registry.add_agent_to_db = AsyncMock(
return_value=_sample_agent_response()
)
self.mock_registry.register_agent = MagicMock()
resp = self.admin_client.post(
"/v1/agents",
json=_sample_agent_config(),
headers={"Authorization": "Bearer k"},
)
assert resp.status_code == 200
def test_should_allow_admin_to_delete_agent(self):
existing = {
"agent_id": "agent-123",
"agent_name": "Existing Agent",
"agent_card_params": _sample_agent_card_params(),
}
with patch("litellm.proxy.proxy_server.prisma_client") as mock_prisma:
mock_prisma.db.litellm_agentstable.find_unique = AsyncMock(
return_value=existing
)
self.mock_registry.delete_agent_from_db = AsyncMock()
self.mock_registry.deregister_agent = MagicMock()
resp = self.admin_client.delete(
"/v1/agents/agent-123", headers={"Authorization": "Bearer k"}
)
assert resp.status_code == 200
class TestCheckAgentManagementPermission:
"""Unit tests for the _check_agent_management_permission helper."""
def test_should_allow_proxy_admin(self):
auth = UserAPIKeyAuth(
user_id="admin", user_role=LitellmUserRoles.PROXY_ADMIN
)
_check_agent_management_permission(auth)
@pytest.mark.parametrize(
"role",
[
LitellmUserRoles.INTERNAL_USER,
LitellmUserRoles.INTERNAL_USER_VIEW_ONLY,
LitellmUserRoles.PROXY_ADMIN_VIEW_ONLY,
],
)
def test_should_block_non_admin_roles(self, role):
from fastapi import HTTPException
auth = UserAPIKeyAuth(user_id="user", user_role=role)
with pytest.raises(HTTPException) as exc_info:
_check_agent_management_permission(auth)
assert exc_info.value.status_code == 403
class TestAgentRoutesIncludesAgentIdPattern:
"""Verify that agent_routes includes the {agent_id} pattern for route access."""
def test_should_include_agent_id_pattern(self):
from litellm.proxy._types import LiteLLMRoutes
assert "/v1/agents/{agent_id}" in LiteLLMRoutes.agent_routes.value

View file

@ -1485,4 +1485,273 @@ async def test_get_objects_resolves_org_by_name():
)
# ---------------------------------------------------------------------------
# Fix 1: OIDC discovery URL resolution
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_resolve_jwks_url_passthrough_for_direct_jwks_url():
"""Non-discovery URLs are returned unchanged."""
from unittest.mock import AsyncMock, MagicMock
from litellm.caching.dual_cache import DualCache
handler = JWTHandler()
handler.update_environment(
prisma_client=None,
user_api_key_cache=DualCache(),
litellm_jwtauth=LiteLLM_JWTAuth(),
)
url = "https://login.microsoftonline.com/common/discovery/keys"
result = await handler._resolve_jwks_url(url)
assert result == url
@pytest.mark.asyncio
async def test_resolve_jwks_url_resolves_oidc_discovery_document():
"""
A .well-known/openid-configuration URL should be fetched and its
jwks_uri returned.
"""
from unittest.mock import AsyncMock, MagicMock, patch
from litellm.caching.dual_cache import DualCache
handler = JWTHandler()
cache = DualCache()
handler.update_environment(
prisma_client=None,
user_api_key_cache=cache,
litellm_jwtauth=LiteLLM_JWTAuth(),
)
discovery_url = "https://login.microsoftonline.com/tenant/.well-known/openid-configuration"
jwks_url = "https://login.microsoftonline.com/tenant/discovery/keys"
mock_response = MagicMock()
mock_response.status_code = 200
mock_response.json.return_value = {"jwks_uri": jwks_url, "issuer": "https://..."}
with patch.object(handler.http_handler, "get", new_callable=AsyncMock, return_value=mock_response) as mock_get:
result = await handler._resolve_jwks_url(discovery_url)
assert result == jwks_url
mock_get.assert_called_once_with(discovery_url)
@pytest.mark.asyncio
async def test_resolve_jwks_url_caches_resolved_jwks_uri():
"""Resolved jwks_uri is cached — second call does not hit the network."""
from unittest.mock import AsyncMock, MagicMock, patch
from litellm.caching.dual_cache import DualCache
handler = JWTHandler()
cache = DualCache()
handler.update_environment(
prisma_client=None,
user_api_key_cache=cache,
litellm_jwtauth=LiteLLM_JWTAuth(),
)
discovery_url = "https://login.microsoftonline.com/tenant/.well-known/openid-configuration"
jwks_url = "https://login.microsoftonline.com/tenant/discovery/keys"
mock_response = MagicMock()
mock_response.json.return_value = {"jwks_uri": jwks_url}
with patch.object(handler.http_handler, "get", new_callable=AsyncMock, return_value=mock_response) as mock_get:
first = await handler._resolve_jwks_url(discovery_url)
second = await handler._resolve_jwks_url(discovery_url)
assert first == jwks_url
assert second == jwks_url
# Network should only be hit once
assert mock_get.call_count == 1
@pytest.mark.asyncio
async def test_resolve_jwks_url_raises_if_no_jwks_uri_in_discovery_doc():
"""Raise a helpful error if the discovery document has no jwks_uri."""
from unittest.mock import AsyncMock, MagicMock, patch
from litellm.caching.dual_cache import DualCache
handler = JWTHandler()
handler.update_environment(
prisma_client=None,
user_api_key_cache=DualCache(),
litellm_jwtauth=LiteLLM_JWTAuth(),
)
discovery_url = "https://example.com/.well-known/openid-configuration"
mock_response = MagicMock()
mock_response.json.return_value = {"issuer": "https://example.com"} # no jwks_uri
with patch.object(handler.http_handler, "get", new_callable=AsyncMock, return_value=mock_response):
with pytest.raises(Exception, match="jwks_uri"):
await handler._resolve_jwks_url(discovery_url)
# ---------------------------------------------------------------------------
# Fix 2: handle array values in team_id_jwt_field (e.g. AAD "roles" claim)
# ---------------------------------------------------------------------------
def _make_jwt_handler(team_id_jwt_field: str) -> JWTHandler:
from litellm.caching.dual_cache import DualCache
handler = JWTHandler()
handler.update_environment(
prisma_client=None,
user_api_key_cache=DualCache(),
litellm_jwtauth=LiteLLM_JWTAuth(team_id_jwt_field=team_id_jwt_field),
)
return handler
def test_get_team_id_returns_first_element_when_roles_is_list():
"""
AAD sends roles as a list. get_team_id() must return the first string
element rather than the raw list (which would later crash with
'unhashable type: list').
"""
handler = _make_jwt_handler("roles")
token = {"oid": "user-oid", "roles": ["team1"]}
result = handler.get_team_id(token=token, default_value=None)
assert result == "team1"
def test_get_team_id_returns_first_element_from_multi_value_roles_list():
"""When roles has multiple entries, the first one is used."""
handler = _make_jwt_handler("roles")
token = {"roles": ["team2", "team1"]}
result = handler.get_team_id(token=token, default_value=None)
assert result == "team2"
def test_get_team_id_returns_default_when_roles_list_is_empty():
"""Empty list should fall back to default_value."""
handler = _make_jwt_handler("roles")
token = {"roles": []}
result = handler.get_team_id(token=token, default_value="fallback")
assert result == "fallback"
def test_get_team_id_still_works_with_string_value():
"""String values (non-array) continue to work as before."""
handler = _make_jwt_handler("appid")
token = {"appid": "my-team-id"}
result = handler.get_team_id(token=token, default_value=None)
assert result == "my-team-id"
def test_get_team_id_list_result_is_hashable():
"""
The value returned by get_team_id() must be hashable so it can be
added to a set (the operation that previously crashed).
"""
handler = _make_jwt_handler("roles")
token = {"roles": ["team1"]}
result = handler.get_team_id(token=token, default_value=None)
# This must not raise TypeError
s: set = set()
s.add(result)
assert "team1" in s
# ---------------------------------------------------------------------------
# Fix 3: helpful error message for dot-notation array indexing (roles.0)
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_find_and_validate_specific_team_id_hints_bracket_notation():
"""
When team_id_jwt_field is set to 'roles.0' (unsupported dot-notation for
array indexing) and no team is found, the exception message should suggest
using 'roles' instead (and explain LiteLLM auto-unwraps list values).
"""
from unittest.mock import MagicMock
from litellm.caching.dual_cache import DualCache
handler = _make_jwt_handler("roles.0")
# token has roles as a list — dot-notation won't find anything
token = {"roles": ["team1"]}
with pytest.raises(Exception) as exc_info:
await JWTAuthManager.find_and_validate_specific_team_id(
jwt_handler=handler,
jwt_valid_token=token,
prisma_client=None,
user_api_key_cache=DualCache(),
parent_otel_span=None,
proxy_logging_obj=MagicMock(),
)
error_msg = str(exc_info.value)
# Should mention the bad field name and suggest the fix
assert "roles.0" in error_msg, f"Expected field name in: {error_msg}"
assert "roles" in error_msg and "list" in error_msg, (
f"Expected hint about using 'roles' instead: {error_msg}"
)
@pytest.mark.asyncio
async def test_find_and_validate_specific_team_id_hints_bracket_index_notation():
"""
When team_id_jwt_field is set to 'roles[0]' (bracket indexing, also unsupported
in get_nested_value) the error message should suggest using 'roles' instead.
"""
from unittest.mock import MagicMock
from litellm.caching.dual_cache import DualCache
handler = _make_jwt_handler("roles[0]")
token = {"roles": ["team1"]}
with pytest.raises(Exception) as exc_info:
await JWTAuthManager.find_and_validate_specific_team_id(
jwt_handler=handler,
jwt_valid_token=token,
prisma_client=None,
user_api_key_cache=DualCache(),
parent_otel_span=None,
proxy_logging_obj=MagicMock(),
)
error_msg = str(exc_info.value)
assert "roles[0]" in error_msg, f"Expected field name in: {error_msg}"
assert "roles" in error_msg and "list" in error_msg, (
f"Expected hint about using 'roles' instead: {error_msg}"
)
@pytest.mark.asyncio
async def test_find_and_validate_specific_team_id_no_hint_for_valid_field():
"""
When team_id_jwt_field is a normal field name (no dot-notation) the
error message should not contain a spurious bracket-notation hint.
"""
from unittest.mock import AsyncMock, MagicMock
from litellm.caching.dual_cache import DualCache
handler = _make_jwt_handler("appid")
token = {} # no appid — triggers the "no team found" path
with pytest.raises(Exception) as exc_info:
await JWTAuthManager.find_and_validate_specific_team_id(
jwt_handler=handler,
jwt_valid_token=token,
prisma_client=None,
user_api_key_cache=DualCache(),
parent_otel_span=None,
proxy_logging_obj=MagicMock(),
)
error_msg = str(exc_info.value)
assert "Hint" not in error_msg

View file

@ -6070,6 +6070,121 @@ async def test_build_key_filter_admin_all_member_overlap():
)
@pytest.mark.asyncio
async def test_build_key_filter_project_id():
"""
Test that project_id is applied as a global AND condition, narrowing all results
to keys that belong to the specified project.
"""
from litellm.proxy.management_endpoints.key_management_endpoints import (
_build_key_filter_conditions,
)
user_id = "user-123"
project_id = "proj-abc"
where = _build_key_filter_conditions(
user_id=user_id,
team_id=None,
organization_id=None,
key_alias=None,
key_hash=None,
exclude_team_id=None,
admin_team_ids=None,
member_team_ids=None,
include_created_by_keys=False,
project_id=project_id,
)
# Should be wrapped in a top-level AND for the project_id filter
assert "AND" in where
and_parts = where["AND"]
assert len(and_parts) == 2
# Second part of AND should be the project_id filter
assert {"project_id": project_id} in and_parts
@pytest.mark.asyncio
async def test_build_key_filter_access_group_id():
"""
Test that access_group_id is applied as a global AND condition using hasSome,
narrowing results to keys whose access_group_ids array contains the given ID.
"""
from litellm.proxy.management_endpoints.key_management_endpoints import (
_build_key_filter_conditions,
)
user_id = "user-123"
access_group_id = "ag-xyz"
where = _build_key_filter_conditions(
user_id=user_id,
team_id=None,
organization_id=None,
key_alias=None,
key_hash=None,
exclude_team_id=None,
admin_team_ids=None,
member_team_ids=None,
include_created_by_keys=False,
access_group_id=access_group_id,
)
# Should be wrapped in a top-level AND for the access_group_id filter
assert "AND" in where
and_parts = where["AND"]
assert len(and_parts) == 2
# Second part of AND should use hasSome for the array field
assert {"access_group_ids": {"hasSome": [access_group_id]}} in and_parts
@pytest.mark.asyncio
async def test_build_key_filter_project_id_and_access_group_id():
"""
Test that project_id and access_group_id stack correctly when both are provided.
Both should be applied as AND conditions, narrowing results to keys that match both.
"""
from litellm.proxy.management_endpoints.key_management_endpoints import (
_build_key_filter_conditions,
)
user_id = "user-123"
project_id = "proj-abc"
access_group_id = "ag-xyz"
where = _build_key_filter_conditions(
user_id=user_id,
team_id=None,
organization_id=None,
key_alias=None,
key_hash=None,
exclude_team_id=None,
admin_team_ids=None,
member_team_ids=None,
include_created_by_keys=False,
project_id=project_id,
access_group_id=access_group_id,
)
# After project_id: {"AND": [visibility_where, {"project_id": ...}]}
# After access_group_id: {"AND": [above, {"access_group_ids": ...}]}
assert "AND" in where
outer_and = where["AND"]
assert len(outer_and) == 2
# The access_group_ids filter is the outermost AND
access_group_filter = outer_and[1]
assert access_group_filter == {"access_group_ids": {"hasSome": [access_group_id]}}
# The project_id filter is nested one level in
inner = outer_and[0]
assert "AND" in inner
inner_and = inner["AND"]
assert {"project_id": project_id} in inner_and
@pytest.mark.asyncio
async def test_get_member_team_ids():
"""

View file

@ -0,0 +1,98 @@
"""
Tests for InFlightRequestsMiddleware.
Verifies that in_flight_requests is incremented during a request and
decremented after it completes, including on errors.
"""
import asyncio
import pytest
from starlette.middleware.base import BaseHTTPMiddleware
from starlette.requests import Request
from starlette.responses import JSONResponse, Response
from starlette.routing import Route
from starlette.testclient import TestClient
from litellm.proxy.middleware.in_flight_requests_middleware import (
InFlightRequestsMiddleware,
get_in_flight_requests,
)
@pytest.fixture(autouse=True)
def reset_state():
"""Reset class-level state between tests."""
InFlightRequestsMiddleware._in_flight = 0
yield
InFlightRequestsMiddleware._in_flight = 0
def _make_app(handler):
from starlette.applications import Starlette
app = Starlette(routes=[Route("/", handler)])
app.add_middleware(InFlightRequestsMiddleware)
return app
# ── Structure ─────────────────────────────────────────────────────────────────
def test_is_not_base_http_middleware():
"""Must be pure ASGI — BaseHTTPMiddleware causes streaming degradation."""
assert not issubclass(InFlightRequestsMiddleware, BaseHTTPMiddleware)
def test_has_asgi_call_protocol():
assert "__call__" in InFlightRequestsMiddleware.__dict__
# ── Counter behaviour ─────────────────────────────────────────────────────────
def test_counter_zero_at_start():
assert get_in_flight_requests() == 0
def test_counter_increments_inside_handler():
captured = []
async def handler(request: Request) -> Response:
captured.append(InFlightRequestsMiddleware.get_count())
return JSONResponse({})
TestClient(_make_app(handler)).get("/")
assert captured == [1]
def test_counter_returns_to_zero_after_request():
async def handler(request: Request) -> Response:
return JSONResponse({})
TestClient(_make_app(handler)).get("/")
assert get_in_flight_requests() == 0
def test_counter_decrements_after_error():
"""Counter must reach 0 even when the handler raises."""
async def handler(request: Request) -> Response:
return Response("boom", status_code=500)
TestClient(_make_app(handler)).get("/")
assert get_in_flight_requests() == 0
def test_non_http_scopes_not_counted():
"""Lifespan / websocket scopes must not touch the counter."""
class _InnerApp:
async def __call__(self, scope, receive, send):
pass
mw = InFlightRequestsMiddleware(_InnerApp())
asyncio.get_event_loop().run_until_complete(
mw({"type": "lifespan"}, None, None) # type: ignore[arg-type]
)
assert get_in_flight_requests() == 0

View file

@ -0,0 +1,75 @@
import pytest
from litellm.proxy.health_check import _update_litellm_params_for_health_check
from litellm.litellm_core_utils.health_check_helpers import HealthCheckHelpers
from unittest.mock import AsyncMock, patch, MagicMock
@pytest.mark.asyncio
async def test_update_litellm_params_max_tokens_default():
"""
Test that max_tokens defaults to 1 for non-wildcard models.
"""
model_info = {}
litellm_params = {"model": "gpt-4"}
updated_params = _update_litellm_params_for_health_check(model_info, litellm_params)
assert updated_params["max_tokens"] == 1
@pytest.mark.asyncio
async def test_update_litellm_params_max_tokens_custom():
"""
Test that max_tokens respects health_check_max_tokens from model_info.
"""
model_info = {"health_check_max_tokens": 5}
litellm_params = {"model": "gpt-4"}
updated_params = _update_litellm_params_for_health_check(model_info, litellm_params)
assert updated_params["max_tokens"] == 5
@pytest.mark.asyncio
async def test_update_litellm_params_max_tokens_wildcard():
"""
Test that max_tokens does NOT default to 1 for wildcard models.
"""
model_info = {}
litellm_params = {"model": "openai/*"}
updated_params = _update_litellm_params_for_health_check(model_info, litellm_params)
# Should not be set to 1
assert "max_tokens" not in updated_params or updated_params["max_tokens"] != 1
@pytest.mark.asyncio
async def test_ahealth_check_wildcard_models_respects_max_tokens():
"""
Test that ahealth_check_wildcard_models respects max_tokens if passed,
otherwise defaults to 10.
"""
with patch(
"litellm.litellm_core_utils.llm_request_utils.pick_cheapest_chat_models_from_llm_provider",
return_value=["gpt-4o-mini"],
), patch("litellm.acompletion", new_callable=AsyncMock):
# Test Case 1: No max_tokens passed, should default to 10
model_params = {}
await HealthCheckHelpers.ahealth_check_wildcard_models(
model="openai/*",
custom_llm_provider="openai",
model_params=model_params,
litellm_logging_obj=MagicMock(),
)
assert model_params["max_tokens"] == 10
# Test Case 2: Custom health_check_max_tokens passed via model_params, should be respected
model_params = {"max_tokens": 3}
await HealthCheckHelpers.ahealth_check_wildcard_models(
model="openai/*",
custom_llm_provider="openai",
model_params=model_params,
litellm_logging_obj=MagicMock(),
)
assert model_params["max_tokens"] == 3

View file

@ -45,3 +45,27 @@ def test_audit_log_masking():
json_before_value = json.loads(audit_log.before_value)
assert json_before_value["token"] == "1q2132r222"
assert json_before_value["key"] == "sk-1*****7890"
def test_internal_jobs_user_has_proxy_admin_role():
"""
Test that the internal jobs system user has PROXY_ADMIN role.
This is critical for key rotation to work properly. The system user needs
PROXY_ADMIN role to bypass team permission checks in
TeamMemberPermissionChecks.can_team_member_execute_key_management_endpoint()
Regression test for: https://github.com/BerriAI/litellm/pull/21896
"""
from litellm.proxy._types import LitellmUserRoles, UserAPIKeyAuth
# Get the system user used for internal jobs like key rotation
system_user = UserAPIKeyAuth.get_litellm_internal_jobs_user_api_key_auth()
# Verify the system user has PROXY_ADMIN role
assert system_user.user_role == LitellmUserRoles.PROXY_ADMIN
# Verify other expected properties
assert system_user.user_id == "system"
assert system_user.team_id == "system"
assert system_user.team_alias == "system"

View file

@ -90,6 +90,72 @@ async def test_acompletion_with_mcp_without_auto_execution_calls_model(monkeypat
assert captured_secret_fields["value"] == {"api_key": "value"}
@pytest.mark.asyncio
async def test_acompletion_with_mcp_passes_mcp_server_auth_headers_to_process_tools(
monkeypatch,
):
"""
Test that MCP auth headers extracted from secret_fields (e.g. x-mcp-linear_config-authorization)
are passed to _process_mcp_tools_without_openai_transform for dynamic auth when fetching tools.
"""
tools = [{"type": "mcp", "server_url": "litellm_proxy"}]
mock_acompletion = AsyncMock(return_value="ok")
captured_process_kwargs = {}
async def mock_process(**kwargs):
captured_process_kwargs.update(kwargs)
return ([], {})
monkeypatch.setattr(
LiteLLM_Proxy_MCP_Handler,
"_should_use_litellm_mcp_gateway",
staticmethod(lambda t: True),
)
monkeypatch.setattr(
LiteLLM_Proxy_MCP_Handler,
"_parse_mcp_tools",
staticmethod(lambda t: (t, [])),
)
monkeypatch.setattr(
LiteLLM_Proxy_MCP_Handler,
"_process_mcp_tools_without_openai_transform",
mock_process,
)
monkeypatch.setattr(
LiteLLM_Proxy_MCP_Handler,
"_transform_mcp_tools_to_openai",
staticmethod(lambda *_, **__: ["openai-tool"]),
)
monkeypatch.setattr(
LiteLLM_Proxy_MCP_Handler,
"_should_auto_execute_tools",
staticmethod(lambda **_: False),
)
# secret_fields with raw_headers containing MCP auth - extract_mcp_headers_from_request
# will parse these and pass to _process_mcp_tools_without_openai_transform
secret_fields = {
"raw_headers": {
"x-mcp-linear_config-authorization": "Bearer linear-token",
},
}
with patch("litellm.acompletion", mock_acompletion):
await acompletion_with_mcp(
model="test-model",
messages=[],
tools=tools,
secret_fields=secret_fields,
)
assert "mcp_server_auth_headers" in captured_process_kwargs
mcp_server_auth_headers = captured_process_kwargs["mcp_server_auth_headers"]
assert mcp_server_auth_headers is not None
assert "linear_config" in mcp_server_auth_headers
assert mcp_server_auth_headers["linear_config"]["Authorization"] == "Bearer linear-token"
@pytest.mark.asyncio
async def test_acompletion_with_mcp_auto_exec_performs_follow_up(monkeypatch):
from litellm.utils import CustomStreamWrapper

View file

@ -90,7 +90,6 @@
"version": "5.2.0",
"resolved": "https://registry.npmjs.org/@alloc/quick-lru/-/quick-lru-5.2.0.tgz",
"integrity": "sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=10"
@ -1772,7 +1771,6 @@
"version": "0.3.13",
"resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz",
"integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@jridgewell/sourcemap-codec": "^1.5.0",
@ -1783,7 +1781,6 @@
"version": "3.1.2",
"resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz",
"integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=6.0.0"
@ -1793,14 +1790,12 @@
"version": "1.5.5",
"resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz",
"integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==",
"dev": true,
"license": "MIT"
},
"node_modules/@jridgewell/trace-mapping": {
"version": "0.3.31",
"resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz",
"integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@jridgewell/resolve-uri": "^3.1.0",
@ -1978,7 +1973,6 @@
"version": "2.1.5",
"resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz",
"integrity": "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==",
"dev": true,
"license": "MIT",
"dependencies": {
"@nodelib/fs.stat": "2.0.5",
@ -1992,7 +1986,6 @@
"version": "2.0.5",
"resolved": "https://registry.npmjs.org/@nodelib/fs.stat/-/fs.stat-2.0.5.tgz",
"integrity": "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">= 8"
@ -2002,7 +1995,6 @@
"version": "1.2.8",
"resolved": "https://registry.npmjs.org/@nodelib/fs.walk/-/fs.walk-1.2.8.tgz",
"integrity": "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@nodelib/fs.scandir": "2.1.5",
@ -2326,7 +2318,7 @@
"version": "1.58.1",
"resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.58.1.tgz",
"integrity": "sha512-6LdVIUERWxQMmUSSQi0I53GgCBYgM2RpGngCPY7hSeju+VrKjq3lvs7HpJoPbDiY5QM5EYRtRX5fvrinnMAz3w==",
"dev": true,
"devOptional": true,
"license": "Apache-2.0",
"dependencies": {
"playwright": "1.58.1"
@ -3431,14 +3423,12 @@
"version": "15.7.15",
"resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.15.tgz",
"integrity": "sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw==",
"dev": true,
"license": "MIT"
},
"node_modules/@types/react": {
"version": "18.2.48",
"resolved": "https://registry.npmjs.org/@types/react/-/react-18.2.48.tgz",
"integrity": "sha512-qboRCl6Ie70DQQG9hhNREz81jqC1cs9EVNcjQ1AU+jH6NFfSAhVVbrrY/+nSF+Bsk4AOwm9Qa61InvMCyV+H3w==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/prop-types": "*",
@ -3480,7 +3470,6 @@
"version": "0.26.0",
"resolved": "https://registry.npmjs.org/@types/scheduler/-/scheduler-0.26.0.tgz",
"integrity": "sha512-WFHp9YUJQ6CKshqoC37iOlHnQSmxNc795UhB26CyBBttrN9svdIrUjl/NjnNmfcwtncN0h/0PPAFWv9ovP8mLA==",
"dev": true,
"license": "MIT"
},
"node_modules/@types/unist": {
@ -4341,14 +4330,12 @@
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/any-promise/-/any-promise-1.3.0.tgz",
"integrity": "sha512-7UvmKalWRt1wgjL1RrGxoSJW/0QZFIegpeGvZG9kjp8vrRu55XTHbwnqq2GpXm9uLbcuhxm3IqX9OB4MZR1b2A==",
"dev": true,
"license": "MIT"
},
"node_modules/anymatch": {
"version": "3.1.3",
"resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.3.tgz",
"integrity": "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==",
"dev": true,
"license": "ISC",
"dependencies": {
"normalize-path": "^3.0.0",
@ -4362,7 +4349,6 @@
"version": "2.3.1",
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz",
"integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=8.6"
@ -4375,7 +4361,6 @@
"version": "5.0.2",
"resolved": "https://registry.npmjs.org/arg/-/arg-5.0.2.tgz",
"integrity": "sha512-PYjyFOLKQ9y57JvQ6QLo8dAgNqswh8M1RMJYdQduT6xbWSgK36P/Z/v+p888pM69jMMfS8Xd8F6I1kQ/I9HUGg==",
"dev": true,
"license": "MIT"
},
"node_modules/argparse": {
@ -4747,7 +4732,6 @@
"version": "2.3.0",
"resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz",
"integrity": "sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=8"
@ -4773,7 +4757,6 @@
"version": "3.0.3",
"resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz",
"integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==",
"dev": true,
"license": "MIT",
"dependencies": {
"fill-range": "^7.1.1"
@ -4889,7 +4872,6 @@
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/camelcase-css/-/camelcase-css-2.0.1.tgz",
"integrity": "sha512-QOSvevhslijgYwRx6Rv7zKdMF8lbRmx+uQGx2+vDc+KI/eBnsy9kit5aj23AgGu3pa4t9AgwbnXWqS+iOY+2aA==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">= 6"
@ -5013,7 +4995,6 @@
"version": "3.6.0",
"resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz",
"integrity": "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==",
"dev": true,
"license": "MIT",
"dependencies": {
"anymatch": "~3.1.2",
@ -5038,7 +5019,6 @@
"version": "5.1.2",
"resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz",
"integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==",
"dev": true,
"license": "ISC",
"dependencies": {
"is-glob": "^4.0.1"
@ -5114,7 +5094,6 @@
"version": "4.1.1",
"resolved": "https://registry.npmjs.org/commander/-/commander-4.1.1.tgz",
"integrity": "sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">= 6"
@ -5175,7 +5154,6 @@
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/cssesc/-/cssesc-3.0.0.tgz",
"integrity": "sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==",
"dev": true,
"license": "MIT",
"bin": {
"cssesc": "bin/cssesc"
@ -5589,14 +5567,12 @@
"version": "1.2.2",
"resolved": "https://registry.npmjs.org/didyoumean/-/didyoumean-1.2.2.tgz",
"integrity": "sha512-gxtyfqMg7GKyhQmb056K7M3xszy/myH8w+B4RT+QXBQsvAOdc3XymqDDPHx1BgPgsdAA5SIifona89YtRATDzw==",
"dev": true,
"license": "Apache-2.0"
},
"node_modules/dlv": {
"version": "1.1.3",
"resolved": "https://registry.npmjs.org/dlv/-/dlv-1.1.3.tgz",
"integrity": "sha512-+HlytyjlPKnIG8XuRG8WvmBP8xs8P71y+SKKS6ZXWoEgLuePxtDoUEiH7WkdePWrQ5JBpE6aoVqfZfJUQkjXwA==",
"dev": true,
"license": "MIT"
},
"node_modules/doctrine": {
@ -6510,7 +6486,6 @@
"version": "1.20.1",
"resolved": "https://registry.npmjs.org/fastq/-/fastq-1.20.1.tgz",
"integrity": "sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw==",
"dev": true,
"license": "ISC",
"dependencies": {
"reusify": "^1.0.4"
@ -6543,7 +6518,6 @@
"version": "6.5.0",
"resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz",
"integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=12.0.0"
@ -6581,7 +6555,6 @@
"version": "7.1.1",
"resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz",
"integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==",
"dev": true,
"license": "MIT",
"dependencies": {
"to-regex-range": "^5.0.1"
@ -6742,7 +6715,6 @@
"version": "2.3.2",
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz",
"integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==",
"dev": true,
"hasInstallScript": true,
"license": "MIT",
"optional": true,
@ -6893,7 +6865,6 @@
"version": "6.0.2",
"resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz",
"integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==",
"dev": true,
"license": "ISC",
"dependencies": {
"is-glob": "^4.0.3"
@ -7391,7 +7362,6 @@
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz",
"integrity": "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==",
"dev": true,
"license": "MIT",
"dependencies": {
"binary-extensions": "^2.0.0"
@ -7444,7 +7414,6 @@
"version": "2.16.1",
"resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.16.1.tgz",
"integrity": "sha512-UfoeMA6fIJ8wTYFEUjelnaGI67v6+N7qXJEvQuIGa99l4xsCruSYOVSQ0uPANn4dAzm8lkYPaKLrrijLq7x23w==",
"dev": true,
"license": "MIT",
"dependencies": {
"hasown": "^2.0.2"
@ -7505,7 +7474,6 @@
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz",
"integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=0.10.0"
@ -7551,7 +7519,6 @@
"version": "4.0.3",
"resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz",
"integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==",
"dev": true,
"license": "MIT",
"dependencies": {
"is-extglob": "^2.1.1"
@ -7600,7 +7567,6 @@
"version": "7.0.0",
"resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz",
"integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=0.12.0"
@ -7877,7 +7843,6 @@
"version": "1.21.7",
"resolved": "https://registry.npmjs.org/jiti/-/jiti-1.21.7.tgz",
"integrity": "sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A==",
"dev": true,
"license": "MIT",
"bin": {
"jiti": "bin/jiti.js"
@ -8163,7 +8128,6 @@
"version": "3.1.3",
"resolved": "https://registry.npmjs.org/lilconfig/-/lilconfig-3.1.3.tgz",
"integrity": "sha512-/vlFKAoH5Cgt3Ie+JLhRbwOsCQePABiU3tJ1egGvyQ+33R/vcwM2Zl2QR/LzjsBeItPt3oSVXapn+m4nQDvpzw==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=14"
@ -8176,7 +8140,6 @@
"version": "1.2.4",
"resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz",
"integrity": "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==",
"dev": true,
"license": "MIT"
},
"node_modules/locate-path": {
@ -8491,7 +8454,6 @@
"version": "1.4.1",
"resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz",
"integrity": "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">= 8"
@ -8943,7 +8905,6 @@
"version": "4.0.8",
"resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz",
"integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==",
"dev": true,
"license": "MIT",
"dependencies": {
"braces": "^3.0.3",
@ -8957,7 +8918,6 @@
"version": "2.3.1",
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz",
"integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=8.6"
@ -9072,7 +9032,6 @@
"version": "2.7.0",
"resolved": "https://registry.npmjs.org/mz/-/mz-2.7.0.tgz",
"integrity": "sha512-z81GNO7nnYMEhrGh9LeymoE4+Yr0Wn5McHIZMK5cfQCl+NDX08sCZgUc9/6MHni9IWuFLm1Z3HTCXu2z9fN62Q==",
"dev": true,
"license": "MIT",
"dependencies": {
"any-promise": "^1.0.0",
@ -9284,7 +9243,6 @@
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz",
"integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=0.10.0"
@ -9303,7 +9261,6 @@
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/object-hash/-/object-hash-3.0.0.tgz",
"integrity": "sha512-RSn9F68PjH9HqtltsSnqYC1XXoWe9Bju5+213R98cNGttag9q9yAOTzdbsqvIa7aNm5WffBZFpWYr2aWrklWAw==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">= 6"
@ -9648,7 +9605,6 @@
"version": "1.0.7",
"resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz",
"integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==",
"dev": true,
"license": "MIT"
},
"node_modules/path-scurry": {
@ -9695,7 +9651,6 @@
"version": "4.0.3",
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz",
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=12"
@ -9708,7 +9663,6 @@
"version": "2.3.0",
"resolved": "https://registry.npmjs.org/pify/-/pify-2.3.0.tgz",
"integrity": "sha512-udgsAY+fTnvv7kI7aaxbqwWNb0AHiB0qBO89PZKPkoTmGOgdbrHDKD+0B2X4uTfJ/FT1R09r9gTsjUjNJotuog==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=0.10.0"
@ -9718,7 +9672,6 @@
"version": "4.0.7",
"resolved": "https://registry.npmjs.org/pirates/-/pirates-4.0.7.tgz",
"integrity": "sha512-TfySrs/5nm8fQJDcBDuUng3VOUKsd7S+zqvbOTiGXHfxX4wK31ard+hoNuvkicM/2YFzlpDgABOevKSsB4G/FA==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">= 6"
@ -9728,7 +9681,7 @@
"version": "1.58.1",
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.58.1.tgz",
"integrity": "sha512-+2uTZHxSCcxjvGc5C891LrS1/NlxglGxzrC4seZiVjcYVQfUa87wBL6rTDqzGjuoWNjnBzRqKmF6zRYGMvQUaQ==",
"dev": true,
"devOptional": true,
"license": "Apache-2.0",
"dependencies": {
"playwright-core": "1.58.1"
@ -9747,7 +9700,7 @@
"version": "1.58.1",
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.58.1.tgz",
"integrity": "sha512-bcWzOaTxcW+VOOGBCQgnaKToLJ65d6AqfLVKEWvexyS3AS6rbXl+xdpYRMGSRBClPvyj44njOWoxjNdL/H9UNg==",
"dev": true,
"devOptional": true,
"license": "Apache-2.0",
"bin": {
"playwright-core": "cli.js"
@ -9770,7 +9723,6 @@
"version": "8.5.6",
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz",
"integrity": "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==",
"dev": true,
"funding": [
{
"type": "opencollective",
@ -9799,7 +9751,6 @@
"version": "15.1.0",
"resolved": "https://registry.npmjs.org/postcss-import/-/postcss-import-15.1.0.tgz",
"integrity": "sha512-hpr+J05B2FVYUAXHeK1YyI267J/dDDhMU6B6civm8hSY1jYJnBXxzKDKDswzJmtLHryrjhnDjqqp/49t8FALew==",
"dev": true,
"license": "MIT",
"dependencies": {
"postcss-value-parser": "^4.0.0",
@ -9817,7 +9768,6 @@
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/postcss-js/-/postcss-js-4.1.0.tgz",
"integrity": "sha512-oIAOTqgIo7q2EOwbhb8UalYePMvYoIeRY2YKntdpFQXNosSu3vLrniGgmH9OKs/qAkfoj5oB3le/7mINW1LCfw==",
"dev": true,
"funding": [
{
"type": "opencollective",
@ -9843,7 +9793,6 @@
"version": "6.0.1",
"resolved": "https://registry.npmjs.org/postcss-load-config/-/postcss-load-config-6.0.1.tgz",
"integrity": "sha512-oPtTM4oerL+UXmx+93ytZVN82RrlY/wPUV8IeDxFrzIjXOLF1pN+EmKPLbubvKHT2HC20xXsCAH2Z+CKV6Oz/g==",
"dev": true,
"funding": [
{
"type": "opencollective",
@ -9886,7 +9835,6 @@
"version": "6.2.0",
"resolved": "https://registry.npmjs.org/postcss-nested/-/postcss-nested-6.2.0.tgz",
"integrity": "sha512-HQbt28KulC5AJzG+cZtj9kvKB93CFCdLvog1WFLf1D+xmMvPGlBstkpTEZfK5+AN9hfJocyBFCNiqyS48bpgzQ==",
"dev": true,
"funding": [
{
"type": "opencollective",
@ -9912,7 +9860,6 @@
"version": "6.1.2",
"resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-6.1.2.tgz",
"integrity": "sha512-Q8qQfPiZ+THO/3ZrOrO0cJJKfpYCagtMUkXbnEfmgUjwXg6z/WBeOyS9APBBPCTSiDV+s4SwQGu8yFsiMRIudg==",
"dev": true,
"license": "MIT",
"dependencies": {
"cssesc": "^3.0.0",
@ -9926,7 +9873,6 @@
"version": "4.2.0",
"resolved": "https://registry.npmjs.org/postcss-value-parser/-/postcss-value-parser-4.2.0.tgz",
"integrity": "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==",
"dev": true,
"license": "MIT"
},
"node_modules/prelude-ls": {
@ -10040,7 +9986,6 @@
"version": "1.2.3",
"resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz",
"integrity": "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==",
"dev": true,
"funding": [
{
"type": "github",
@ -10829,7 +10774,6 @@
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/read-cache/-/read-cache-1.0.0.tgz",
"integrity": "sha512-Owdv/Ft7IjOgm/i0xvNDZ1LrRANRfew4b2prF3OWMQLxLfu3bS8FVhCsrSCMK4lR56Y9ya+AThoTpDCTxCmpRA==",
"dev": true,
"license": "MIT",
"dependencies": {
"pify": "^2.3.0"
@ -10839,7 +10783,6 @@
"version": "3.6.0",
"resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz",
"integrity": "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==",
"dev": true,
"license": "MIT",
"dependencies": {
"picomatch": "^2.2.1"
@ -10852,7 +10795,6 @@
"version": "2.3.1",
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz",
"integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=8.6"
@ -11117,7 +11059,6 @@
"version": "1.22.11",
"resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.11.tgz",
"integrity": "sha512-RfqAvLnMl313r7c9oclB1HhUEAezcpLjz95wFH4LVuhk9JF/r22qmVP9AMmOU4vMX7Q8pN8jwNg/CSpdFnMjTQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"is-core-module": "^2.16.1",
@ -11158,7 +11099,6 @@
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/reusify/-/reusify-1.1.0.tgz",
"integrity": "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==",
"dev": true,
"license": "MIT",
"engines": {
"iojs": ">=1.0.0",
@ -11214,7 +11154,6 @@
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz",
"integrity": "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==",
"dev": true,
"funding": [
{
"type": "github",
@ -11855,7 +11794,6 @@
"version": "3.35.1",
"resolved": "https://registry.npmjs.org/sucrase/-/sucrase-3.35.1.tgz",
"integrity": "sha512-DhuTmvZWux4H1UOnWMB3sk0sbaCVOoQZjv8u1rDoTV0HTdGem9hkAZtl4JZy8P2z4Bg0nT+YMeOFyVr4zcG5Tw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@jridgewell/gen-mapping": "^0.3.2",
@ -11891,7 +11829,6 @@
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz",
"integrity": "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">= 0.4"
@ -11927,7 +11864,6 @@
"version": "3.4.19",
"resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-3.4.19.tgz",
"integrity": "sha512-3ofp+LL8E+pK/JuPLPggVAIaEuhvIz4qNcf3nA1Xn2o/7fb7s/TYpHhwGDv1ZU3PkBluUVaF8PyCHcm48cKLWQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@alloc/quick-lru": "^5.2.0",
@ -11965,7 +11901,6 @@
"version": "3.3.3",
"resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.3.tgz",
"integrity": "sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@nodelib/fs.stat": "^2.0.2",
@ -11982,7 +11917,6 @@
"version": "5.1.2",
"resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz",
"integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==",
"dev": true,
"license": "ISC",
"dependencies": {
"is-glob": "^4.0.1"
@ -12010,7 +11944,6 @@
"version": "3.3.1",
"resolved": "https://registry.npmjs.org/thenify/-/thenify-3.3.1.tgz",
"integrity": "sha512-RVZSIV5IG10Hk3enotrhvz0T9em6cyHBLkH/YAZuKqd8hRkKhSfCGIcP2KUY0EPxndzANBmNllzWPwak+bheSw==",
"dev": true,
"license": "MIT",
"dependencies": {
"any-promise": "^1.0.0"
@ -12020,7 +11953,6 @@
"version": "1.6.0",
"resolved": "https://registry.npmjs.org/thenify-all/-/thenify-all-1.6.0.tgz",
"integrity": "sha512-RNxQH/qI8/t3thXJDwcstUO4zeqo64+Uy/+sNVRBx4Xn2OX+OZ9oP+iJnNFqplFra2ZUVeKCSa2oVWi3T4uVmA==",
"dev": true,
"license": "MIT",
"dependencies": {
"thenify": ">= 3.1.0 < 4"
@ -12062,7 +11994,6 @@
"version": "0.2.15",
"resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz",
"integrity": "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"fdir": "^6.5.0",
@ -12129,7 +12060,6 @@
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz",
"integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"is-number": "^7.0.0"
@ -12217,7 +12147,6 @@
"version": "0.1.13",
"resolved": "https://registry.npmjs.org/ts-interface-checker/-/ts-interface-checker-0.1.13.tgz",
"integrity": "sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA==",
"dev": true,
"license": "Apache-2.0"
},
"node_modules/tsconfig-paths": {
@ -12334,7 +12263,7 @@
"version": "5.3.3",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.3.3.tgz",
"integrity": "sha512-pXWcraxM0uxAS+tN0AG/BF2TyqmHO014Z070UsJ+pFvYuRSq8KH8DmWpnbXe0pEPDHXZV3FcAbJkijJ5oNEnWw==",
"dev": true,
"devOptional": true,
"license": "Apache-2.0",
"bin": {
"tsc": "bin/tsc",
@ -12536,7 +12465,6 @@
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz",
"integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==",
"dev": true,
"license": "MIT"
},
"node_modules/uuid": {
@ -12990,7 +12918,7 @@
"version": "8.19.0",
"resolved": "https://registry.npmjs.org/ws/-/ws-8.19.0.tgz",
"integrity": "sha512-blAT2mjOEIi0ZzruJfIhb3nps74PRWTCz1IjglWEEpQl5XS/UNama6u2/rjFkDDouqr4L67ry+1aGIALViWjDg==",
"dev": true,
"devOptional": true,
"license": "MIT",
"engines": {
"node": ">=10.0.0"

View file

@ -50,6 +50,7 @@ const mockKeys: KeyResponse[] = [
config: {},
user_id: "user-1",
team_id: null,
project_id: null,
max_parallel_requests: 10,
metadata: {},
tpm_limit: 1000,
@ -105,6 +106,7 @@ const mockKeys: KeyResponse[] = [
config: {},
user_id: "user-2",
team_id: "team-1",
project_id: "project-1",
max_parallel_requests: 5,
metadata: {},
tpm_limit: 500,
@ -396,6 +398,76 @@ describe("useKeys", () => {
},
);
});
it("should pass projectID filter to the API", async () => {
mockFetch.mockResolvedValueOnce({
ok: true,
json: async () => ({
keys: [mockKeys[1]],
total_count: 1,
current_page: 1,
total_pages: 1,
}),
});
const { result } = renderHook(
() => useKeys(1, 10, { projectID: "project-1" }),
{ wrapper },
);
await waitFor(() => {
expect(result.current.isLoading).toBe(false);
});
const callUrl = mockFetch.mock.calls[0][0];
expect(callUrl).toContain("project_id=project-1");
expect(result.current.data?.keys).toHaveLength(1);
expect(result.current.data?.keys[0].project_id).toBe("project-1");
});
it("should pass both projectID and teamID filters to the API", async () => {
mockFetch.mockResolvedValueOnce({
ok: true,
json: async () => ({
keys: [mockKeys[1]],
total_count: 1,
current_page: 1,
total_pages: 1,
}),
});
const { result } = renderHook(
() => useKeys(1, 10, { projectID: "project-1", teamID: "team-1" }),
{ wrapper },
);
await waitFor(() => {
expect(result.current.isLoading).toBe(false);
});
const callUrl = mockFetch.mock.calls[0][0];
expect(callUrl).toContain("project_id=project-1");
expect(callUrl).toContain("team_id=team-1");
});
it("should not include project_id param when projectID is null", async () => {
mockFetch.mockResolvedValueOnce({
ok: true,
json: async () => mockKeysResponse,
});
const { result } = renderHook(
() => useKeys(1, 10, { projectID: null }),
{ wrapper },
);
await waitFor(() => {
expect(result.current.isLoading).toBe(false);
});
const callUrl = mockFetch.mock.calls[0][0];
expect(callUrl).not.toContain("project_id");
});
});
describe("useDeletedKeys", () => {

View file

@ -33,6 +33,7 @@ export interface DeletedKeysResponse {
export interface KeyListCallOptions {
organizationID?: string | null;
teamID?: string | null;
projectID?: string | null;
selectedKeyAlias?: string | null;
userID?: string | null;
keyHash?: string | null;
@ -57,6 +58,7 @@ const keyListCall = async (
const params = new URLSearchParams(
Object.entries({
team_id: options.teamID,
project_id: options.projectID,
organization_id: options.organizationID,
key_alias: options.selectedKeyAlias,
key_hash: options.keyHash,

View file

@ -0,0 +1,63 @@
import { useQuery, useQueryClient } from "@tanstack/react-query";
import {
getProxyBaseUrl,
getGlobalLitellmHeaderName,
deriveErrorMessage,
handleError,
} from "@/components/networking";
import { all_admin_roles } from "@/utils/roles";
import useAuthorized from "@/app/(dashboard)/hooks/useAuthorized";
import { ProjectResponse, projectKeys } from "./useProjects";
// ── Fetch function ───────────────────────────────────────────────────────────
const fetchProjectDetails = async (
accessToken: string,
projectId: string,
): Promise<ProjectResponse> => {
const baseUrl = getProxyBaseUrl();
const url = `${baseUrl}/project/info?project_id=${encodeURIComponent(projectId)}`;
const response = await fetch(url, {
method: "GET",
headers: {
[getGlobalLitellmHeaderName()]: `Bearer ${accessToken}`,
"Content-Type": "application/json",
},
});
if (!response.ok) {
const errorData = await response.json();
const errorMessage = deriveErrorMessage(errorData);
handleError(errorMessage);
throw new Error(errorMessage);
}
return response.json();
};
// ── Hook ─────────────────────────────────────────────────────────────────────
export const useProjectDetails = (projectId?: string) => {
const { accessToken, userRole } = useAuthorized();
const queryClient = useQueryClient();
return useQuery<ProjectResponse>({
queryKey: projectKeys.detail(projectId!),
queryFn: async () => fetchProjectDetails(accessToken!, projectId!),
enabled:
Boolean(accessToken && projectId) &&
all_admin_roles.includes(userRole || ""),
// Seed from the list cache when available
initialData: () => {
if (!projectId) return undefined;
const projects = queryClient.getQueryData<ProjectResponse[]>(
projectKeys.list({}),
);
return projects?.find((p) => p.project_id === projectId);
},
});
};

View file

@ -0,0 +1,75 @@
import { useMutation, useQueryClient } from "@tanstack/react-query";
import {
getProxyBaseUrl,
getGlobalLitellmHeaderName,
deriveErrorMessage,
handleError,
} from "@/components/networking";
import useAuthorized from "@/app/(dashboard)/hooks/useAuthorized";
import { ProjectResponse, projectKeys } from "./useProjects";
// ── Types ────────────────────────────────────────────────────────────────────
export interface ProjectUpdateParams {
project_alias?: string;
description?: string;
team_id?: string;
models?: string[];
max_budget?: number;
blocked?: boolean;
metadata?: Record<string, unknown>;
model_rpm_limit?: Record<string, number>;
model_tpm_limit?: Record<string, number>;
}
// ── Fetch function ───────────────────────────────────────────────────────────
const updateProject = async (
accessToken: string,
projectId: string,
params: ProjectUpdateParams,
): Promise<ProjectResponse> => {
const baseUrl = getProxyBaseUrl();
const url = `${baseUrl}/project/update`;
const response = await fetch(url, {
method: "POST",
headers: {
[getGlobalLitellmHeaderName()]: `Bearer ${accessToken}`,
"Content-Type": "application/json",
},
body: JSON.stringify({ project_id: projectId, ...params }),
});
if (!response.ok) {
const errorData = await response.json();
const errorMessage = deriveErrorMessage(errorData);
handleError(errorMessage);
throw new Error(errorMessage);
}
return response.json();
};
// ── Hook ─────────────────────────────────────────────────────────────────────
export const useUpdateProject = () => {
const { accessToken } = useAuthorized();
const queryClient = useQueryClient();
return useMutation<
ProjectResponse,
Error,
{ projectId: string; params: ProjectUpdateParams }
>({
mutationFn: async ({ projectId, params }) => {
if (!accessToken) {
throw new Error("Access token is required");
}
return updateProject(accessToken, projectId, params);
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: projectKeys.all });
},
});
};

View file

@ -0,0 +1,148 @@
import React from "react";
import { describe, it, expect, vi, beforeEach } from "vitest";
import { screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { renderWithProviders } from "../../../tests/test-utils";
import AddMarginForm from "./add_margin_form";
import { MarginConfig } from "./types";
vi.mock("../provider_info_helpers", () => ({
Providers: {
OpenAI: "OpenAI",
Anthropic: "Anthropic",
},
provider_map: {
OpenAI: "openai",
Anthropic: "anthropic",
},
providerLogoMap: {
OpenAI: "https://example.com/openai.png",
Anthropic: "https://example.com/anthropic.png",
},
}));
vi.mock("./provider_display_helpers", () => ({
handleImageError: vi.fn(),
}));
const DEFAULT_PROPS = {
marginConfig: {} as MarginConfig,
selectedProvider: undefined,
marginType: "percentage" as const,
percentageValue: "",
fixedAmountValue: "",
onProviderChange: vi.fn(),
onMarginTypeChange: vi.fn(),
onPercentageChange: vi.fn(),
onFixedAmountChange: vi.fn(),
onAddProvider: vi.fn(),
};
describe("AddMarginForm", () => {
beforeEach(() => {
vi.clearAllMocks();
});
it("should render", () => {
renderWithProviders(<AddMarginForm {...DEFAULT_PROPS} />);
expect(screen.getByRole("button", { name: /add provider margin/i })).toBeInTheDocument();
});
it("should show the percentage input when marginType is percentage", () => {
renderWithProviders(<AddMarginForm {...DEFAULT_PROPS} marginType="percentage" />);
expect(screen.getByPlaceholderText("10")).toBeInTheDocument();
});
it("should show the fixed amount input when marginType is fixed", () => {
renderWithProviders(<AddMarginForm {...DEFAULT_PROPS} marginType="fixed" />);
expect(screen.getByPlaceholderText("0.001")).toBeInTheDocument();
});
it("should not show the fixed amount input when marginType is percentage", () => {
renderWithProviders(<AddMarginForm {...DEFAULT_PROPS} marginType="percentage" />);
expect(screen.queryByPlaceholderText("0.001")).not.toBeInTheDocument();
});
it("should not show the percentage input when marginType is fixed", () => {
renderWithProviders(<AddMarginForm {...DEFAULT_PROPS} marginType="fixed" />);
expect(screen.queryByPlaceholderText("10")).not.toBeInTheDocument();
});
it("should show the Percentage-based and Fixed Amount radio options", () => {
renderWithProviders(<AddMarginForm {...DEFAULT_PROPS} />);
expect(screen.getByText("Percentage-based")).toBeInTheDocument();
expect(screen.getByText("Fixed Amount")).toBeInTheDocument();
});
it("should disable the submit button when no provider is selected (percentage mode)", () => {
renderWithProviders(
<AddMarginForm {...DEFAULT_PROPS} selectedProvider={undefined} percentageValue="10" />
);
expect(screen.getByRole("button", { name: /add provider margin/i })).toBeDisabled();
});
it("should disable the submit button when provider is selected but no percentage value (percentage mode)", () => {
renderWithProviders(
<AddMarginForm {...DEFAULT_PROPS} selectedProvider="OpenAI" percentageValue="" />
);
expect(screen.getByRole("button", { name: /add provider margin/i })).toBeDisabled();
});
it("should enable the submit button when provider and percentage value are both provided", () => {
renderWithProviders(
<AddMarginForm {...DEFAULT_PROPS} selectedProvider="OpenAI" percentageValue="10" />
);
expect(screen.getByRole("button", { name: /add provider margin/i })).not.toBeDisabled();
});
it("should disable the submit button in fixed mode when no fixed amount is provided", () => {
renderWithProviders(
<AddMarginForm
{...DEFAULT_PROPS}
selectedProvider="OpenAI"
marginType="fixed"
fixedAmountValue=""
/>
);
expect(screen.getByRole("button", { name: /add provider margin/i })).toBeDisabled();
});
it("should enable the submit button in fixed mode when provider and fixed amount are provided", () => {
renderWithProviders(
<AddMarginForm
{...DEFAULT_PROPS}
selectedProvider="OpenAI"
marginType="fixed"
fixedAmountValue="0.001"
/>
);
expect(screen.getByRole("button", { name: /add provider margin/i })).not.toBeDisabled();
});
it("should call onAddProvider when the enabled submit button is clicked", async () => {
const onAddProvider = vi.fn();
const user = userEvent.setup();
renderWithProviders(
<AddMarginForm
{...DEFAULT_PROPS}
selectedProvider="OpenAI"
percentageValue="10"
onAddProvider={onAddProvider}
/>
);
await user.click(screen.getByRole("button", { name: /add provider margin/i }));
expect(onAddProvider).toHaveBeenCalledTimes(1);
});
it("should call onMarginTypeChange when the Fixed Amount radio is clicked", async () => {
const onMarginTypeChange = vi.fn();
const user = userEvent.setup();
renderWithProviders(
<AddMarginForm {...DEFAULT_PROPS} onMarginTypeChange={onMarginTypeChange} />
);
await user.click(screen.getByText("Fixed Amount"));
expect(onMarginTypeChange).toHaveBeenCalledWith("fixed");
});
});

View file

@ -0,0 +1,98 @@
import React from "react";
import { describe, it, expect, vi, beforeEach } from "vitest";
import { screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { renderWithProviders } from "../../../tests/test-utils";
import AddProviderForm from "./add_provider_form";
import { DiscountConfig } from "./types";
vi.mock("../provider_info_helpers", () => ({
Providers: {
OpenAI: "OpenAI",
Anthropic: "Anthropic",
},
provider_map: {
OpenAI: "openai",
Anthropic: "anthropic",
},
providerLogoMap: {
OpenAI: "https://example.com/openai.png",
Anthropic: "https://example.com/anthropic.png",
},
}));
vi.mock("./provider_display_helpers", () => ({
handleImageError: vi.fn(),
}));
const DEFAULT_PROPS = {
discountConfig: {} as DiscountConfig,
selectedProvider: undefined,
newDiscount: "",
onProviderChange: vi.fn(),
onDiscountChange: vi.fn(),
onAddProvider: vi.fn(),
};
describe("AddProviderForm", () => {
beforeEach(() => {
vi.clearAllMocks();
});
it("should render", () => {
renderWithProviders(<AddProviderForm {...DEFAULT_PROPS} />);
expect(screen.getByRole("button", { name: /add provider discount/i })).toBeInTheDocument();
});
it("should render the discount percentage input field", () => {
renderWithProviders(<AddProviderForm {...DEFAULT_PROPS} />);
expect(screen.getByPlaceholderText("5")).toBeInTheDocument();
});
it("should disable the submit button when no provider is selected and no discount is entered", () => {
renderWithProviders(<AddProviderForm {...DEFAULT_PROPS} />);
expect(screen.getByRole("button", { name: /add provider discount/i })).toBeDisabled();
});
it("should disable the submit button when a provider is selected but no discount is entered", () => {
renderWithProviders(
<AddProviderForm {...DEFAULT_PROPS} selectedProvider="OpenAI" newDiscount="" />
);
expect(screen.getByRole("button", { name: /add provider discount/i })).toBeDisabled();
});
it("should disable the submit button when a discount is entered but no provider is selected", () => {
renderWithProviders(
<AddProviderForm {...DEFAULT_PROPS} selectedProvider={undefined} newDiscount="5" />
);
expect(screen.getByRole("button", { name: /add provider discount/i })).toBeDisabled();
});
it("should enable the submit button when both a provider and a discount value are provided", () => {
renderWithProviders(
<AddProviderForm {...DEFAULT_PROPS} selectedProvider="OpenAI" newDiscount="5" />
);
expect(screen.getByRole("button", { name: /add provider discount/i })).not.toBeDisabled();
});
it("should call onAddProvider when the enabled submit button is clicked", async () => {
const onAddProvider = vi.fn();
const user = userEvent.setup();
renderWithProviders(
<AddProviderForm
{...DEFAULT_PROPS}
selectedProvider="OpenAI"
newDiscount="5"
onAddProvider={onAddProvider}
/>
);
await user.click(screen.getByRole("button", { name: /add provider discount/i }));
expect(onAddProvider).toHaveBeenCalledTimes(1);
});
it("should show the percent sign next to the discount input", () => {
renderWithProviders(<AddProviderForm {...DEFAULT_PROPS} />);
expect(screen.getByText("%")).toBeInTheDocument();
});
});

View file

@ -0,0 +1,201 @@
import React from "react";
import { describe, it, expect, vi, beforeEach } from "vitest";
import { screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { renderWithProviders } from "../../../tests/test-utils";
import CostTrackingSettings from "./cost_tracking_settings";
// Mock sub-hooks so we can control their state without network calls
const mockDiscountConfig = vi.fn(() => ({}));
const mockMarginConfig = vi.fn(() => ({}));
vi.mock("./use_discount_config", () => ({
useDiscountConfig: () => ({
discountConfig: mockDiscountConfig(),
fetchDiscountConfig: vi.fn().mockResolvedValue(undefined),
handleAddProvider: vi.fn().mockResolvedValue(true),
handleRemoveProvider: vi.fn().mockResolvedValue(undefined),
handleDiscountChange: vi.fn().mockResolvedValue(undefined),
}),
}));
vi.mock("./use_margin_config", () => ({
useMarginConfig: () => ({
marginConfig: mockMarginConfig(),
fetchMarginConfig: vi.fn().mockResolvedValue(undefined),
handleAddMargin: vi.fn().mockResolvedValue(true),
handleRemoveMargin: vi.fn().mockResolvedValue(undefined),
handleMarginChange: vi.fn().mockResolvedValue(undefined),
}),
}));
vi.mock("./pricing_calculator/index", () => ({
default: () => <div data-testid="pricing-calculator">Pricing Calculator</div>,
}));
vi.mock("../playground/llm_calls/fetch_models", () => ({
fetchAvailableModels: vi.fn().mockResolvedValue([]),
}));
vi.mock("../HelpLink", () => ({
DocsMenu: () => null,
}));
vi.mock("./how_it_works", () => ({
default: () => <div data-testid="how-it-works">How It Works</div>,
}));
vi.mock("../provider_info_helpers", () => ({
Providers: { OpenAI: "OpenAI" },
provider_map: { OpenAI: "openai" },
providerLogoMap: {},
}));
vi.mock("./provider_display_helpers", () => ({
getProviderDisplayInfo: vi.fn(() => ({ displayName: "OpenAI", logo: "", enumKey: "OpenAI" })),
handleImageError: vi.fn(),
}));
const ADMIN_PROPS = {
userID: "user-1",
userRole: "proxy_admin",
accessToken: "test-token",
};
describe("CostTrackingSettings", () => {
beforeEach(() => {
vi.clearAllMocks();
mockDiscountConfig.mockReturnValue({});
mockMarginConfig.mockReturnValue({});
});
it("should return nothing when accessToken is null", () => {
const { container } = renderWithProviders(
<CostTrackingSettings userID="user-1" userRole="proxy_admin" accessToken={null} />
);
expect(container.firstChild).toBeNull();
});
it("should render the page title", () => {
renderWithProviders(<CostTrackingSettings {...ADMIN_PROPS} />);
expect(screen.getByText("Cost Tracking Settings")).toBeInTheDocument();
});
it("should show the Provider Discounts accordion header for proxy_admin", () => {
renderWithProviders(<CostTrackingSettings {...ADMIN_PROPS} />);
expect(screen.getByText("Provider Discounts")).toBeInTheDocument();
});
it("should show the Fee/Price Margin accordion header for proxy_admin", () => {
renderWithProviders(<CostTrackingSettings {...ADMIN_PROPS} />);
expect(screen.getByText("Fee/Price Margin")).toBeInTheDocument();
});
it("should always show the Pricing Calculator section", () => {
renderWithProviders(<CostTrackingSettings {...ADMIN_PROPS} />);
// The accordion header text appears in the DOM; getAllByText tolerates duplicates
expect(screen.getAllByText("Pricing Calculator").length).toBeGreaterThan(0);
});
it("should show the pricing calculator component", async () => {
renderWithProviders(<CostTrackingSettings {...ADMIN_PROPS} />);
expect(await screen.findByTestId("pricing-calculator")).toBeInTheDocument();
});
it("should not show Provider Discounts section for a non-admin role", () => {
renderWithProviders(
<CostTrackingSettings userID="user-1" userRole="internal_user" accessToken="test-token" />
);
expect(screen.queryByText("Provider Discounts")).not.toBeInTheDocument();
});
it("should not show Fee/Price Margin section for a non-admin role", () => {
renderWithProviders(
<CostTrackingSettings userID="user-1" userRole="internal_user" accessToken="test-token" />
);
expect(screen.queryByText("Fee/Price Margin")).not.toBeInTheDocument();
});
it("should show Provider Discounts for the 'Admin' role as well", () => {
renderWithProviders(
<CostTrackingSettings userID="user-1" userRole="Admin" accessToken="test-token" />
);
expect(screen.getByText("Provider Discounts")).toBeInTheDocument();
});
it("should show the subtitle describing discount/margin configuration", () => {
renderWithProviders(<CostTrackingSettings {...ADMIN_PROPS} />);
expect(
screen.getByText(/configure cost discounts and margins/i)
).toBeInTheDocument();
});
describe("Add Provider Discount modal", () => {
it("should open the Add Provider Discount modal when the button is clicked", async () => {
const user = userEvent.setup();
renderWithProviders(<CostTrackingSettings {...ADMIN_PROPS} />);
// The button lives inside the Provider Discounts accordion — click the header to expand first
const accordionHeader = screen.getByText("Provider Discounts").closest("button");
if (accordionHeader) {
await user.click(accordionHeader);
}
const addButton = await screen.findByRole("button", { name: /add provider discount/i });
await user.click(addButton);
expect(
await screen.findByText("Add Provider Discount", { selector: "h2" })
).toBeInTheDocument();
});
});
describe("Add Provider Margin modal", () => {
it("should open the Add Provider Margin modal when the button is clicked", async () => {
const user = userEvent.setup();
renderWithProviders(<CostTrackingSettings {...ADMIN_PROPS} />);
const accordionHeader = screen.getByText("Fee/Price Margin").closest("button");
if (accordionHeader) {
await user.click(accordionHeader);
}
const addButton = await screen.findByRole("button", { name: /add provider margin/i });
await user.click(addButton);
expect(
await screen.findByText("Add Provider Margin", { selector: "h2" })
).toBeInTheDocument();
});
});
describe("empty state messages", () => {
it("should show the empty state message when no discount config is loaded", async () => {
mockDiscountConfig.mockReturnValue({});
renderWithProviders(<CostTrackingSettings {...ADMIN_PROPS} />);
const accordionHeader = screen.getByText("Provider Discounts").closest("button");
if (accordionHeader) {
await userEvent.setup().click(accordionHeader);
}
expect(
await screen.findByText(/no provider discounts configured/i)
).toBeInTheDocument();
});
it("should show the empty state message when no margin config is loaded", async () => {
mockMarginConfig.mockReturnValue({});
renderWithProviders(<CostTrackingSettings {...ADMIN_PROPS} />);
const accordionHeader = screen.getByText("Fee/Price Margin").closest("button");
if (accordionHeader) {
await userEvent.setup().click(accordionHeader);
}
expect(
await screen.findByText(/no provider margins configured/i)
).toBeInTheDocument();
});
});
});

View file

@ -0,0 +1,95 @@
import React from "react";
import { describe, it, expect, vi, beforeEach } from "vitest";
import { screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { renderWithProviders } from "../../../tests/test-utils";
import HowItWorks from "./how_it_works";
vi.mock("@/app/(dashboard)/api-reference/components/CodeBlock", () => ({
default: ({ code }: { code: string }) => <pre data-testid="code-block">{code}</pre>,
}));
describe("HowItWorks", () => {
beforeEach(() => {
vi.clearAllMocks();
});
it("should render", () => {
renderWithProviders(<HowItWorks />);
expect(screen.getByText("Cost Calculation")).toBeInTheDocument();
});
it("should display the cost calculation formula", () => {
renderWithProviders(<HowItWorks />);
expect(screen.getByText(/final_cost = base_cost/i)).toBeInTheDocument();
});
it("should display the valid range information", () => {
renderWithProviders(<HowItWorks />);
expect(screen.getByText(/0% and 100%/i)).toBeInTheDocument();
});
it("should render the code block with a curl example", () => {
renderWithProviders(<HowItWorks />);
expect(screen.getByTestId("code-block")).toBeInTheDocument();
expect(screen.getByTestId("code-block").textContent).toContain("curl");
});
it("should show the response header names for discount verification", () => {
renderWithProviders(<HowItWorks />);
expect(screen.getByText("x-litellm-response-cost")).toBeInTheDocument();
expect(screen.getByText("x-litellm-response-cost-original")).toBeInTheDocument();
expect(screen.getByText("x-litellm-response-cost-discount-amount")).toBeInTheDocument();
});
it("should not show calculated results initially when no input is provided", () => {
renderWithProviders(<HowItWorks />);
expect(screen.queryByText("Calculated Results")).not.toBeInTheDocument();
});
it("should not show calculated results when only response cost is entered", async () => {
const user = userEvent.setup();
renderWithProviders(<HowItWorks />);
const responseCostInput = screen.getByPlaceholderText("0.0171938125");
await user.type(responseCostInput, "0.01");
expect(screen.queryByText("Calculated Results")).not.toBeInTheDocument();
});
it("should not show calculated results when only discount amount is entered", async () => {
const user = userEvent.setup();
renderWithProviders(<HowItWorks />);
const discountAmountInput = screen.getByPlaceholderText("0.0009049375");
await user.type(discountAmountInput, "0.001");
expect(screen.queryByText("Calculated Results")).not.toBeInTheDocument();
});
it("should show calculated results when both fields are filled", async () => {
const user = userEvent.setup();
renderWithProviders(<HowItWorks />);
const responseCostInput = screen.getByPlaceholderText("0.0171938125");
const discountAmountInput = screen.getByPlaceholderText("0.0009049375");
await user.type(responseCostInput, "0.0171938125");
await user.type(discountAmountInput, "0.0009049375");
expect(await screen.findByText("Calculated Results")).toBeInTheDocument();
});
it("should show original cost, final cost, and discount amount in results", async () => {
const user = userEvent.setup();
renderWithProviders(<HowItWorks />);
await user.type(screen.getByPlaceholderText("0.0171938125"), "0.0171938125");
await user.type(screen.getByPlaceholderText("0.0009049375"), "0.0009049375");
expect(await screen.findByText("Original Cost:")).toBeInTheDocument();
expect(screen.getByText("Final Cost:")).toBeInTheDocument();
expect(screen.getByText("Discount Amount:")).toBeInTheDocument();
expect(screen.getByText("Discount Applied:")).toBeInTheDocument();
});
});

View file

@ -0,0 +1,241 @@
import React from "react";
import { describe, it, expect, vi, beforeEach } from "vitest";
import { screen, within } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { renderWithProviders } from "../../../tests/test-utils";
import ProviderDiscountTable from "./provider_discount_table";
vi.mock("@heroicons/react/outline", () => ({
TrashIcon: function TrashIcon() { return null; },
PencilAltIcon: function PencilAltIcon() { return null; },
CheckIcon: function CheckIcon() { return null; },
XIcon: function XIcon() { return null; },
}));
vi.mock("@tremor/react", () => ({
Table: ({ children }: any) => <table>{children}</table>,
TableHead: ({ children }: any) => <thead>{children}</thead>,
TableRow: ({ children }: any) => <tr>{children}</tr>,
TableHeaderCell: ({ children }: any) => <th>{children}</th>,
TableBody: ({ children }: any) => <tbody>{children}</tbody>,
TableCell: ({ children }: any) => <td>{children}</td>,
Text: ({ children }: any) => <span>{children}</span>,
TextInput: ({ value, onValueChange, onKeyDown, placeholder, ...rest }: any) => (
<input
value={value}
onChange={(e) => onValueChange?.(e.target.value)}
onKeyDown={onKeyDown}
placeholder={placeholder}
{...rest}
/>
),
Icon: ({ icon: IconComponent, onClick }: any) => {
const name = IconComponent?.displayName ?? IconComponent?.name ?? "icon";
return <button onClick={onClick} aria-label={name} />;
},
}));
vi.mock("./provider_display_helpers", () => ({
getProviderDisplayInfo: vi.fn((providerValue: string) => ({
displayName: providerValue === "openai" ? "OpenAI" : providerValue,
logo: providerValue === "openai" ? "https://example.com/openai.png" : "",
enumKey: providerValue === "openai" ? "OpenAI" : null,
})),
handleImageError: vi.fn(),
}));
const DEFAULT_DISCOUNT_CONFIG = {
openai: 0.05,
anthropic: 0.1,
};
describe("ProviderDiscountTable", () => {
const onDiscountChange = vi.fn();
const onRemoveProvider = vi.fn();
beforeEach(() => {
vi.clearAllMocks();
});
it("should render", () => {
renderWithProviders(
<ProviderDiscountTable
discountConfig={DEFAULT_DISCOUNT_CONFIG}
onDiscountChange={onDiscountChange}
onRemoveProvider={onRemoveProvider}
/>
);
expect(screen.getByRole("table")).toBeInTheDocument();
});
it("should render the table headers", () => {
renderWithProviders(
<ProviderDiscountTable
discountConfig={DEFAULT_DISCOUNT_CONFIG}
onDiscountChange={onDiscountChange}
onRemoveProvider={onRemoveProvider}
/>
);
expect(screen.getByText("Provider")).toBeInTheDocument();
expect(screen.getByText("Discount Percentage")).toBeInTheDocument();
expect(screen.getByText("Actions")).toBeInTheDocument();
});
it("should display provider display names in the table", () => {
renderWithProviders(
<ProviderDiscountTable
discountConfig={DEFAULT_DISCOUNT_CONFIG}
onDiscountChange={onDiscountChange}
onRemoveProvider={onRemoveProvider}
/>
);
expect(screen.getByText("OpenAI")).toBeInTheDocument();
});
it("should display the formatted discount percentage", () => {
renderWithProviders(
<ProviderDiscountTable
discountConfig={{ openai: 0.05 }}
onDiscountChange={onDiscountChange}
onRemoveProvider={onRemoveProvider}
/>
);
expect(screen.getByText("5.0%")).toBeInTheDocument();
});
it("should show a text input when the edit icon is clicked", async () => {
const user = userEvent.setup();
renderWithProviders(
<ProviderDiscountTable
discountConfig={{ openai: 0.05 }}
onDiscountChange={onDiscountChange}
onRemoveProvider={onRemoveProvider}
/>
);
const pencilButton = screen.getByRole("button", { name: /PencilAltIcon/i });
await user.click(pencilButton);
expect(screen.getByPlaceholderText("5")).toBeInTheDocument();
});
it("should hide the formatted percentage when in edit mode", async () => {
const user = userEvent.setup();
renderWithProviders(
<ProviderDiscountTable
discountConfig={{ openai: 0.05 }}
onDiscountChange={onDiscountChange}
onRemoveProvider={onRemoveProvider}
/>
);
await user.click(screen.getByRole("button", { name: /PencilAltIcon/i }));
expect(screen.queryByText("5.0%")).not.toBeInTheDocument();
});
it("should call onDiscountChange with the new value when the save icon is clicked", async () => {
const user = userEvent.setup();
renderWithProviders(
<ProviderDiscountTable
discountConfig={{ openai: 0.05 }}
onDiscountChange={onDiscountChange}
onRemoveProvider={onRemoveProvider}
/>
);
await user.click(screen.getByRole("button", { name: /PencilAltIcon/i }));
const input = screen.getByPlaceholderText("5");
await user.clear(input);
await user.type(input, "10");
await user.click(screen.getByRole("button", { name: /CheckIcon/i }));
expect(onDiscountChange).toHaveBeenCalledWith("openai", "0.1");
});
it("should restore the display view after saving", async () => {
const user = userEvent.setup();
renderWithProviders(
<ProviderDiscountTable
discountConfig={{ openai: 0.05 }}
onDiscountChange={onDiscountChange}
onRemoveProvider={onRemoveProvider}
/>
);
await user.click(screen.getByRole("button", { name: /PencilAltIcon/i }));
await user.click(screen.getByRole("button", { name: /CheckIcon/i }));
expect(screen.queryByPlaceholderText("5")).not.toBeInTheDocument();
});
it("should cancel edit mode when the cancel icon is clicked", async () => {
const user = userEvent.setup();
renderWithProviders(
<ProviderDiscountTable
discountConfig={{ openai: 0.05 }}
onDiscountChange={onDiscountChange}
onRemoveProvider={onRemoveProvider}
/>
);
await user.click(screen.getByRole("button", { name: /PencilAltIcon/i }));
await user.click(screen.getByRole("button", { name: /XIcon/i }));
expect(screen.queryByPlaceholderText("5")).not.toBeInTheDocument();
expect(onDiscountChange).not.toHaveBeenCalled();
expect(screen.getByText("5.0%")).toBeInTheDocument();
});
it("should not call onDiscountChange when canceling edit", async () => {
const user = userEvent.setup();
renderWithProviders(
<ProviderDiscountTable
discountConfig={{ openai: 0.05 }}
onDiscountChange={onDiscountChange}
onRemoveProvider={onRemoveProvider}
/>
);
await user.click(screen.getByRole("button", { name: /PencilAltIcon/i }));
await user.click(screen.getByRole("button", { name: /XIcon/i }));
expect(onDiscountChange).not.toHaveBeenCalled();
});
it("should call onRemoveProvider with the provider key and display name when the trash icon is clicked", async () => {
const user = userEvent.setup();
renderWithProviders(
<ProviderDiscountTable
discountConfig={{ openai: 0.05 }}
onDiscountChange={onDiscountChange}
onRemoveProvider={onRemoveProvider}
/>
);
await user.click(screen.getByRole("button", { name: /TrashIcon/i }));
expect(onRemoveProvider).toHaveBeenCalledWith("openai", "OpenAI");
});
it("should not call onDiscountChange when the entered value is out of range", async () => {
const user = userEvent.setup();
renderWithProviders(
<ProviderDiscountTable
discountConfig={{ openai: 0.05 }}
onDiscountChange={onDiscountChange}
onRemoveProvider={onRemoveProvider}
/>
);
await user.click(screen.getByRole("button", { name: /PencilAltIcon/i }));
const input = screen.getByPlaceholderText("5");
await user.clear(input);
await user.type(input, "150");
await user.click(screen.getByRole("button", { name: /CheckIcon/i }));
expect(onDiscountChange).not.toHaveBeenCalled();
});
});

View file

@ -0,0 +1,91 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { getProviderDisplayInfo, getProviderBackendValue, handleImageError } from "./provider_display_helpers";
vi.mock("../provider_info_helpers", () => ({
Providers: {
OpenAI: "OpenAI",
Anthropic: "Anthropic",
Azure: "Azure",
},
provider_map: {
OpenAI: "openai",
Anthropic: "anthropic",
Azure: "azure",
},
providerLogoMap: {
OpenAI: "https://example.com/openai.png",
Anthropic: "https://example.com/anthropic.png",
Azure: "https://example.com/azure.png",
},
}));
describe("getProviderDisplayInfo", () => {
it("should return display name and logo for a known backend provider value", () => {
const info = getProviderDisplayInfo("openai");
expect(info.displayName).toBe("OpenAI");
expect(info.logo).toBe("https://example.com/openai.png");
expect(info.enumKey).toBe("OpenAI");
});
it("should return the raw value as display name for an unknown provider", () => {
const info = getProviderDisplayInfo("my-custom-provider");
expect(info.displayName).toBe("my-custom-provider");
expect(info.logo).toBe("");
expect(info.enumKey).toBeNull();
});
it("should match a provider by its backend value regardless of casing", () => {
const info = getProviderDisplayInfo("anthropic");
expect(info.displayName).toBe("Anthropic");
expect(info.enumKey).toBe("Anthropic");
});
});
describe("getProviderBackendValue", () => {
it("should return the backend value for a known provider enum key", () => {
expect(getProviderBackendValue("OpenAI")).toBe("openai");
});
it("should return the backend value for another known provider", () => {
expect(getProviderBackendValue("Anthropic")).toBe("anthropic");
});
it("should return null for an unknown enum key", () => {
expect(getProviderBackendValue("UnknownProvider")).toBeNull();
});
});
describe("handleImageError", () => {
it("should replace the img element with a fallback div showing the first letter", () => {
const img = document.createElement("img");
const parent = document.createElement("div");
parent.appendChild(img);
const event = { target: img } as any;
handleImageError(event, "OpenAI");
expect(parent.querySelector("img")).toBeNull();
const fallback = parent.firstChild as HTMLElement;
expect(fallback.tagName).toBe("DIV");
expect(fallback.textContent).toBe("O");
});
it("should use the first character of the fallback text as the label", () => {
const img = document.createElement("img");
const parent = document.createElement("div");
parent.appendChild(img);
const event = { target: img } as any;
handleImageError(event, "Anthropic");
const fallback = parent.firstChild as HTMLElement;
expect(fallback.textContent).toBe("A");
});
it("should do nothing if the image has no parent element", () => {
const img = document.createElement("img");
const event = { target: img } as any;
// Should not throw
expect(() => handleImageError(event, "OpenAI")).not.toThrow();
});
});

View file

@ -0,0 +1,246 @@
import React from "react";
import { describe, it, expect, vi, beforeEach } from "vitest";
import { screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { renderWithProviders } from "../../../tests/test-utils";
import ProviderMarginTable from "./provider_margin_table";
vi.mock("@heroicons/react/outline", () => ({
TrashIcon: function TrashIcon() { return null; },
PencilAltIcon: function PencilAltIcon() { return null; },
CheckIcon: function CheckIcon() { return null; },
XIcon: function XIcon() { return null; },
}));
vi.mock("@tremor/react", () => ({
Table: ({ children }: any) => <table>{children}</table>,
TableHead: ({ children }: any) => <thead>{children}</thead>,
TableRow: ({ children }: any) => <tr>{children}</tr>,
TableHeaderCell: ({ children }: any) => <th>{children}</th>,
TableBody: ({ children }: any) => <tbody>{children}</tbody>,
TableCell: ({ children }: any) => <td>{children}</td>,
Text: ({ children }: any) => <span>{children}</span>,
TextInput: ({ value, onValueChange, placeholder, autoFocus, className }: any) => (
<input
value={value}
onChange={(e) => onValueChange?.(e.target.value)}
placeholder={placeholder}
autoFocus={autoFocus}
className={className}
/>
),
Icon: ({ icon: IconComponent, onClick }: any) => {
const name = IconComponent?.displayName ?? IconComponent?.name ?? "icon";
return <button onClick={onClick} aria-label={name} />;
},
}));
vi.mock("./provider_display_helpers", () => ({
getProviderDisplayInfo: vi.fn((providerValue: string) => {
if (providerValue === "openai") return { displayName: "OpenAI", logo: "", enumKey: "OpenAI" };
if (providerValue === "anthropic") return { displayName: "Anthropic", logo: "", enumKey: "Anthropic" };
return { displayName: providerValue, logo: "", enumKey: null };
}),
handleImageError: vi.fn(),
}));
describe("ProviderMarginTable", () => {
const onMarginChange = vi.fn();
const onRemoveProvider = vi.fn();
beforeEach(() => {
vi.clearAllMocks();
});
it("should render", () => {
renderWithProviders(
<ProviderMarginTable
marginConfig={{ openai: 0.1 }}
onMarginChange={onMarginChange}
onRemoveProvider={onRemoveProvider}
/>
);
expect(screen.getByRole("table")).toBeInTheDocument();
});
it("should render the table headers", () => {
renderWithProviders(
<ProviderMarginTable
marginConfig={{ openai: 0.1 }}
onMarginChange={onMarginChange}
onRemoveProvider={onRemoveProvider}
/>
);
expect(screen.getByText("Provider")).toBeInTheDocument();
expect(screen.getByText("Margin")).toBeInTheDocument();
expect(screen.getByText("Actions")).toBeInTheDocument();
});
it("should display the provider display name", () => {
renderWithProviders(
<ProviderMarginTable
marginConfig={{ openai: 0.1 }}
onMarginChange={onMarginChange}
onRemoveProvider={onRemoveProvider}
/>
);
expect(screen.getByText("OpenAI")).toBeInTheDocument();
});
it("should display the global provider as 'Global (All Providers)'", () => {
renderWithProviders(
<ProviderMarginTable
marginConfig={{ global: 0.05 }}
onMarginChange={onMarginChange}
onRemoveProvider={onRemoveProvider}
/>
);
expect(screen.getByText("Global (All Providers)")).toBeInTheDocument();
});
it("should display a numeric margin as a percentage", () => {
renderWithProviders(
<ProviderMarginTable
marginConfig={{ openai: 0.1 }}
onMarginChange={onMarginChange}
onRemoveProvider={onRemoveProvider}
/>
);
expect(screen.getByText("10.0%")).toBeInTheDocument();
});
it("should display a fixed amount margin with dollar sign", () => {
renderWithProviders(
<ProviderMarginTable
marginConfig={{ openai: { fixed_amount: 0.001 } }}
onMarginChange={onMarginChange}
onRemoveProvider={onRemoveProvider}
/>
);
expect(screen.getByText("$0.001000")).toBeInTheDocument();
});
it("should display a combined percentage and fixed margin", () => {
renderWithProviders(
<ProviderMarginTable
marginConfig={{ openai: { percentage: 0.1, fixed_amount: 0.001 } }}
onMarginChange={onMarginChange}
onRemoveProvider={onRemoveProvider}
/>
);
expect(screen.getByText(/10\.0%.*\$0\.001000/)).toBeInTheDocument();
});
it("should show edit inputs when the pencil icon is clicked", async () => {
const user = userEvent.setup();
renderWithProviders(
<ProviderMarginTable
marginConfig={{ openai: 0.1 }}
onMarginChange={onMarginChange}
onRemoveProvider={onRemoveProvider}
/>
);
await user.click(screen.getByRole("button", { name: /PencilAltIcon/i }));
expect(screen.getByPlaceholderText("10")).toBeInTheDocument();
expect(screen.getByPlaceholderText("0.001")).toBeInTheDocument();
});
it("should call onMarginChange with a percentage value when save is clicked", async () => {
const user = userEvent.setup();
renderWithProviders(
<ProviderMarginTable
marginConfig={{ openai: 0.1 }}
onMarginChange={onMarginChange}
onRemoveProvider={onRemoveProvider}
/>
);
await user.click(screen.getByRole("button", { name: /PencilAltIcon/i }));
const percentInput = screen.getByPlaceholderText("10");
await user.clear(percentInput);
await user.type(percentInput, "20");
await user.click(screen.getByRole("button", { name: /CheckIcon/i }));
expect(onMarginChange).toHaveBeenCalledWith("openai", 0.2);
});
it("should cancel edit mode without calling onMarginChange when X is clicked", async () => {
const user = userEvent.setup();
renderWithProviders(
<ProviderMarginTable
marginConfig={{ openai: 0.1 }}
onMarginChange={onMarginChange}
onRemoveProvider={onRemoveProvider}
/>
);
await user.click(screen.getByRole("button", { name: /PencilAltIcon/i }));
await user.click(screen.getByRole("button", { name: /XIcon/i }));
expect(onMarginChange).not.toHaveBeenCalled();
expect(screen.queryByPlaceholderText("10")).not.toBeInTheDocument();
});
it("should call onRemoveProvider with provider key and display name when trash is clicked", async () => {
const user = userEvent.setup();
renderWithProviders(
<ProviderMarginTable
marginConfig={{ openai: 0.1 }}
onMarginChange={onMarginChange}
onRemoveProvider={onRemoveProvider}
/>
);
await user.click(screen.getByRole("button", { name: /TrashIcon/i }));
expect(onRemoveProvider).toHaveBeenCalledWith("openai", "OpenAI");
});
it("should call onRemoveProvider with 'Global' display name for the global provider", async () => {
const user = userEvent.setup();
renderWithProviders(
<ProviderMarginTable
marginConfig={{ global: 0.05 }}
onMarginChange={onMarginChange}
onRemoveProvider={onRemoveProvider}
/>
);
await user.click(screen.getByRole("button", { name: /TrashIcon/i }));
expect(onRemoveProvider).toHaveBeenCalledWith("global", "Global");
});
describe("when both percentage and fixed amount are entered", () => {
it("should call onMarginChange with an object containing both values", async () => {
const user = userEvent.setup();
renderWithProviders(
<ProviderMarginTable
marginConfig={{ openai: 0.1 }}
onMarginChange={onMarginChange}
onRemoveProvider={onRemoveProvider}
/>
);
await user.click(screen.getByRole("button", { name: /PencilAltIcon/i }));
const percentInput = screen.getByPlaceholderText("10");
await user.clear(percentInput);
await user.type(percentInput, "5");
const fixedInput = screen.getByPlaceholderText("0.001");
await user.type(fixedInput, "0.002");
await user.click(screen.getByRole("button", { name: /CheckIcon/i }));
expect(onMarginChange).toHaveBeenCalledWith("openai", {
percentage: 0.05,
fixed_amount: 0.002,
});
});
});
});

View file

@ -0,0 +1,244 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { renderHook, act } from "@testing-library/react";
import { useDiscountConfig } from "./use_discount_config";
import NotificationsManager from "@/components/molecules/notifications_manager";
vi.mock("@/components/networking", () => ({
getProxyBaseUrl: vi.fn(() => ""),
getGlobalLitellmHeaderName: vi.fn(() => "Authorization"),
}));
vi.mock("./provider_display_helpers", () => ({
getProviderBackendValue: vi.fn((enumKey: string) => {
const map: Record<string, string> = {
OpenAI: "openai",
Anthropic: "anthropic",
};
return map[enumKey] ?? null;
}),
}));
vi.mock("../provider_info_helpers", () => ({
Providers: {
OpenAI: "OpenAI",
Anthropic: "Anthropic",
},
}));
describe("useDiscountConfig", () => {
beforeEach(() => {
vi.clearAllMocks();
});
describe("fetchDiscountConfig", () => {
it("should populate discountConfig with fetched values on success", async () => {
vi.spyOn(global, "fetch").mockResolvedValueOnce({
ok: true,
json: async () => ({ values: { openai: 0.05, anthropic: 0.1 } }),
} as Response);
const { result } = renderHook(() => useDiscountConfig({ accessToken: "test-token" }));
await act(async () => {
await result.current.fetchDiscountConfig();
});
expect(result.current.discountConfig).toEqual({ openai: 0.05, anthropic: 0.1 });
});
it("should set an empty config when the response has no values", async () => {
vi.spyOn(global, "fetch").mockResolvedValueOnce({
ok: true,
json: async () => ({ values: {} }),
} as Response);
const { result } = renderHook(() => useDiscountConfig({ accessToken: "test-token" }));
await act(async () => {
await result.current.fetchDiscountConfig();
});
expect(result.current.discountConfig).toEqual({});
});
it("should show an error notification when the fetch throws", async () => {
vi.spyOn(global, "fetch").mockRejectedValueOnce(new Error("Network error"));
const { result } = renderHook(() => useDiscountConfig({ accessToken: "test-token" }));
await act(async () => {
await result.current.fetchDiscountConfig();
});
expect(NotificationsManager.fromBackend).toHaveBeenCalledWith(
expect.stringMatching(/failed to fetch/i)
);
});
});
describe("handleAddProvider", () => {
it("should return false and notify when no provider is selected", async () => {
const { result } = renderHook(() => useDiscountConfig({ accessToken: "test-token" }));
let success: boolean;
await act(async () => {
success = await result.current.handleAddProvider(undefined, "5");
});
expect(success!).toBe(false);
expect(NotificationsManager.fromBackend).toHaveBeenCalled();
});
it("should return false and notify when no discount is provided", async () => {
const { result } = renderHook(() => useDiscountConfig({ accessToken: "test-token" }));
let success: boolean;
await act(async () => {
success = await result.current.handleAddProvider("OpenAI", "");
});
expect(success!).toBe(false);
expect(NotificationsManager.fromBackend).toHaveBeenCalled();
});
it("should return false and notify when the discount exceeds 100", async () => {
const { result } = renderHook(() => useDiscountConfig({ accessToken: "test-token" }));
let success: boolean;
await act(async () => {
success = await result.current.handleAddProvider("OpenAI", "150");
});
expect(success!).toBe(false);
expect(NotificationsManager.fromBackend).toHaveBeenCalledWith(
expect.stringMatching(/0%.*100%/i)
);
});
it("should return false and notify when the provider already exists in the config", async () => {
vi.spyOn(global, "fetch")
.mockResolvedValueOnce({
ok: true,
json: async () => ({ values: { openai: 0.05 } }),
} as Response)
.mockResolvedValue({ ok: true, json: async () => ({}) } as Response);
const { result } = renderHook(() => useDiscountConfig({ accessToken: "test-token" }));
await act(async () => {
await result.current.fetchDiscountConfig();
});
let success: boolean;
await act(async () => {
success = await result.current.handleAddProvider("OpenAI", "10");
});
expect(success!).toBe(false);
expect(NotificationsManager.fromBackend).toHaveBeenCalledWith(
expect.stringMatching(/already exists/i)
);
});
it("should save the config and return true on a valid new provider", async () => {
vi.spyOn(global, "fetch")
.mockResolvedValueOnce({ ok: true, json: async () => ({ values: {} }) } as Response)
.mockResolvedValueOnce({ ok: true, json: async () => ({}) } as Response)
.mockResolvedValueOnce({ ok: true, json: async () => ({ values: { openai: 0.05 } }) } as Response);
const { result } = renderHook(() => useDiscountConfig({ accessToken: "test-token" }));
await act(async () => {
await result.current.fetchDiscountConfig();
});
let success: boolean;
await act(async () => {
success = await result.current.handleAddProvider("OpenAI", "5");
});
expect(success!).toBe(true);
expect(NotificationsManager.success).toHaveBeenCalled();
});
});
describe("handleRemoveProvider", () => {
it("should remove the provider from the config and save", async () => {
vi.spyOn(global, "fetch")
.mockResolvedValueOnce({
ok: true,
json: async () => ({ values: { openai: 0.05, anthropic: 0.1 } }),
} as Response)
.mockResolvedValueOnce({ ok: true, json: async () => ({}) } as Response)
.mockResolvedValueOnce({
ok: true,
json: async () => ({ values: { anthropic: 0.1 } }),
} as Response);
const { result } = renderHook(() => useDiscountConfig({ accessToken: "test-token" }));
await act(async () => {
await result.current.fetchDiscountConfig();
});
expect(result.current.discountConfig).toHaveProperty("openai");
await act(async () => {
await result.current.handleRemoveProvider("openai");
});
// The optimistic update removes openai immediately
expect(result.current.discountConfig).not.toHaveProperty("openai");
});
});
describe("handleDiscountChange", () => {
it("should update the discount value and save when the value is valid", async () => {
vi.spyOn(global, "fetch")
.mockResolvedValueOnce({
ok: true,
json: async () => ({ values: { openai: 0.05 } }),
} as Response)
.mockResolvedValueOnce({ ok: true, json: async () => ({}) } as Response)
.mockResolvedValueOnce({
ok: true,
json: async () => ({ values: { openai: 0.1 } }),
} as Response);
const { result } = renderHook(() => useDiscountConfig({ accessToken: "test-token" }));
await act(async () => {
await result.current.fetchDiscountConfig();
});
await act(async () => {
await result.current.handleDiscountChange("openai", "0.1");
});
// Optimistic update applied immediately
expect(result.current.discountConfig["openai"]).toBe(0.1);
});
it("should not save when the value is greater than 1 (invalid fraction)", async () => {
vi.spyOn(global, "fetch").mockResolvedValueOnce({
ok: true,
json: async () => ({ values: { openai: 0.05 } }),
} as Response);
const { result } = renderHook(() => useDiscountConfig({ accessToken: "test-token" }));
await act(async () => {
await result.current.fetchDiscountConfig();
});
// Clear mocks after the initial fetch so we can check that no PATCH was made
vi.clearAllMocks();
await act(async () => {
await result.current.handleDiscountChange("openai", "1.5");
});
expect(global.fetch).not.toHaveBeenCalled();
});
});
});

View file

@ -0,0 +1,316 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { renderHook, act } from "@testing-library/react";
import { useMarginConfig } from "./use_margin_config";
import NotificationsManager from "@/components/molecules/notifications_manager";
vi.mock("@/components/networking", () => ({
getProxyBaseUrl: vi.fn(() => ""),
getGlobalLitellmHeaderName: vi.fn(() => "Authorization"),
}));
vi.mock("./provider_display_helpers", () => ({
getProviderBackendValue: vi.fn((enumKey: string) => {
const map: Record<string, string> = {
OpenAI: "openai",
Anthropic: "anthropic",
};
return map[enumKey] ?? null;
}),
}));
vi.mock("../provider_info_helpers", () => ({
Providers: {
OpenAI: "OpenAI",
Anthropic: "Anthropic",
},
}));
describe("useMarginConfig", () => {
beforeEach(() => {
vi.clearAllMocks();
});
describe("fetchMarginConfig", () => {
it("should populate marginConfig with fetched values on success", async () => {
vi.spyOn(global, "fetch").mockResolvedValueOnce({
ok: true,
json: async () => ({ values: { openai: 0.1, global: 0.05 } }),
} as Response);
const { result } = renderHook(() => useMarginConfig({ accessToken: "test-token" }));
await act(async () => {
await result.current.fetchMarginConfig();
});
expect(result.current.marginConfig).toEqual({ openai: 0.1, global: 0.05 });
});
it("should set an empty config when the response has no values", async () => {
vi.spyOn(global, "fetch").mockResolvedValueOnce({
ok: true,
json: async () => ({ values: {} }),
} as Response);
const { result } = renderHook(() => useMarginConfig({ accessToken: "test-token" }));
await act(async () => {
await result.current.fetchMarginConfig();
});
expect(result.current.marginConfig).toEqual({});
});
it("should show an error notification when fetch throws", async () => {
vi.spyOn(global, "fetch").mockRejectedValueOnce(new Error("Network error"));
const { result } = renderHook(() => useMarginConfig({ accessToken: "test-token" }));
await act(async () => {
await result.current.fetchMarginConfig();
});
expect(NotificationsManager.fromBackend).toHaveBeenCalledWith(
expect.stringMatching(/failed to fetch/i)
);
});
});
describe("handleAddMargin", () => {
it("should return false and notify when no provider is selected", async () => {
const { result } = renderHook(() => useMarginConfig({ accessToken: "test-token" }));
let success: boolean;
await act(async () => {
success = await result.current.handleAddMargin({
selectedProvider: undefined,
marginType: "percentage",
percentageValue: "10",
fixedAmountValue: "",
});
});
expect(success!).toBe(false);
expect(NotificationsManager.fromBackend).toHaveBeenCalled();
});
it("should return false and notify when percentage is out of range", async () => {
const { result } = renderHook(() => useMarginConfig({ accessToken: "test-token" }));
let success: boolean;
await act(async () => {
success = await result.current.handleAddMargin({
selectedProvider: "OpenAI",
marginType: "percentage",
percentageValue: "2000",
fixedAmountValue: "",
});
});
expect(success!).toBe(false);
expect(NotificationsManager.fromBackend).toHaveBeenCalledWith(
expect.stringMatching(/0%.*1000%/i)
);
});
it("should return false when the provider already has a margin configured", async () => {
vi.spyOn(global, "fetch")
.mockResolvedValueOnce({
ok: true,
json: async () => ({ values: { openai: 0.05 } }),
} as Response)
.mockResolvedValue({ ok: true, json: async () => ({}) } as Response);
const { result } = renderHook(() => useMarginConfig({ accessToken: "test-token" }));
await act(async () => {
await result.current.fetchMarginConfig();
});
let success: boolean;
await act(async () => {
success = await result.current.handleAddMargin({
selectedProvider: "OpenAI",
marginType: "percentage",
percentageValue: "10",
fixedAmountValue: "",
});
});
expect(success!).toBe(false);
expect(NotificationsManager.fromBackend).toHaveBeenCalledWith(
expect.stringMatching(/already exists/i)
);
});
it("should save a percentage margin and return true for a valid new provider", async () => {
vi.spyOn(global, "fetch")
.mockResolvedValueOnce({ ok: true, json: async () => ({ values: {} }) } as Response)
.mockResolvedValueOnce({ ok: true, json: async () => ({}) } as Response)
.mockResolvedValueOnce({ ok: true, json: async () => ({ values: { openai: 0.1 } }) } as Response);
const { result } = renderHook(() => useMarginConfig({ accessToken: "test-token" }));
await act(async () => {
await result.current.fetchMarginConfig();
});
let success: boolean;
await act(async () => {
success = await result.current.handleAddMargin({
selectedProvider: "OpenAI",
marginType: "percentage",
percentageValue: "10",
fixedAmountValue: "",
});
});
expect(success!).toBe(true);
expect(NotificationsManager.success).toHaveBeenCalled();
});
it("should save a fixed amount margin and return true for a valid new provider", async () => {
vi.spyOn(global, "fetch")
.mockResolvedValueOnce({ ok: true, json: async () => ({ values: {} }) } as Response)
.mockResolvedValueOnce({ ok: true, json: async () => ({}) } as Response)
.mockResolvedValueOnce({
ok: true,
json: async () => ({ values: { openai: { fixed_amount: 0.001 } } }),
} as Response);
const { result } = renderHook(() => useMarginConfig({ accessToken: "test-token" }));
await act(async () => {
await result.current.fetchMarginConfig();
});
let success: boolean;
await act(async () => {
success = await result.current.handleAddMargin({
selectedProvider: "OpenAI",
marginType: "fixed",
percentageValue: "",
fixedAmountValue: "0.001",
});
});
expect(success!).toBe(true);
expect(NotificationsManager.success).toHaveBeenCalled();
});
it("should accept the global provider without provider_map lookup", async () => {
vi.spyOn(global, "fetch")
.mockResolvedValueOnce({ ok: true, json: async () => ({ values: {} }) } as Response)
.mockResolvedValueOnce({ ok: true, json: async () => ({}) } as Response)
.mockResolvedValueOnce({
ok: true,
json: async () => ({ values: { global: 0.05 } }),
} as Response);
const { result } = renderHook(() => useMarginConfig({ accessToken: "test-token" }));
await act(async () => {
await result.current.fetchMarginConfig();
});
let success: boolean;
await act(async () => {
success = await result.current.handleAddMargin({
selectedProvider: "global",
marginType: "percentage",
percentageValue: "5",
fixedAmountValue: "",
});
});
expect(success!).toBe(true);
});
});
describe("handleRemoveMargin", () => {
it("should remove the provider from the config and save", async () => {
vi.spyOn(global, "fetch")
.mockResolvedValueOnce({
ok: true,
json: async () => ({ values: { openai: 0.1, anthropic: 0.05 } }),
} as Response)
.mockResolvedValueOnce({ ok: true, json: async () => ({}) } as Response)
.mockResolvedValueOnce({
ok: true,
json: async () => ({ values: { anthropic: 0.05 } }),
} as Response);
const { result } = renderHook(() => useMarginConfig({ accessToken: "test-token" }));
await act(async () => {
await result.current.fetchMarginConfig();
});
expect(result.current.marginConfig).toHaveProperty("openai");
await act(async () => {
await result.current.handleRemoveMargin("openai");
});
expect(result.current.marginConfig).not.toHaveProperty("openai");
});
});
describe("handleMarginChange", () => {
it("should update the margin value for a provider and save", async () => {
vi.spyOn(global, "fetch")
.mockResolvedValueOnce({
ok: true,
json: async () => ({ values: { openai: 0.1 } }),
} as Response)
.mockResolvedValueOnce({ ok: true, json: async () => ({}) } as Response)
.mockResolvedValueOnce({
ok: true,
json: async () => ({ values: { openai: 0.2 } }),
} as Response);
const { result } = renderHook(() => useMarginConfig({ accessToken: "test-token" }));
await act(async () => {
await result.current.fetchMarginConfig();
});
await act(async () => {
await result.current.handleMarginChange("openai", 0.2);
});
// Optimistic update applied immediately
expect(result.current.marginConfig["openai"]).toBe(0.2);
});
it("should update the margin with a complex value (percentage + fixed)", async () => {
vi.spyOn(global, "fetch")
.mockResolvedValueOnce({
ok: true,
json: async () => ({ values: { openai: 0.1 } }),
} as Response)
.mockResolvedValueOnce({ ok: true, json: async () => ({}) } as Response)
.mockResolvedValueOnce({
ok: true,
json: async () => ({
values: { openai: { percentage: 0.05, fixed_amount: 0.001 } },
}),
} as Response);
const { result } = renderHook(() => useMarginConfig({ accessToken: "test-token" }));
await act(async () => {
await result.current.fetchMarginConfig();
});
await act(async () => {
await result.current.handleMarginChange("openai", { percentage: 0.05, fixed_amount: 0.001 });
});
expect(result.current.marginConfig["openai"]).toEqual({
percentage: 0.05,
fixed_amount: 0.001,
});
});
});
});

View file

@ -0,0 +1,317 @@
import { useProjectDetails } from "@/app/(dashboard)/hooks/projects/useProjectDetails";
import { useTeam } from "@/app/(dashboard)/hooks/teams/useTeams";
import {
Button,
Card,
Col,
Descriptions,
Empty,
Flex,
Layout,
Progress,
Row,
Spin,
Tag,
theme,
Typography,
} from "antd";
import { LoadingOutlined } from "@ant-design/icons";
import { BarChart } from "@tremor/react";
import { ArrowLeftIcon, DollarSignIcon, EditIcon, UsersIcon } from "lucide-react";
import { useMemo, useState } from "react";
import DefaultProxyAdminTag from "../common_components/DefaultProxyAdminTag";
import { EditProjectModal } from "./ProjectModals/EditProjectModal";
import { ProjectKeysSection } from "./ProjectKeysSection";
const { Title, Text } = Typography;
const { Content } = Layout;
interface TeamInfoShape {
team_id: string;
team_alias?: string;
models?: string[];
max_budget?: number | null;
budget_duration?: string | null;
spend?: number;
members_with_roles?: { user_id: string; role: string }[];
}
interface ProjectDetailProps {
projectId: string;
onBack: () => void;
}
export function ProjectDetail({ projectId, onBack }: ProjectDetailProps) {
const { data: project, isLoading } = useProjectDetails(projectId);
const { data: teamData } = useTeam(project?.team_id ?? undefined);
// teamInfoCall returns { team_id, team_info: {...}, keys, team_memberships }
const teamInfo: TeamInfoShape | undefined = ((teamData as unknown as { team_info?: TeamInfoShape })?.team_info ??
teamData) as TeamInfoShape | undefined;
const { token } = theme.useToken();
const [isEditModalVisible, setIsEditModalVisible] = useState(false);
const spend = project?.spend ?? 0;
const maxBudget = project?.litellm_budget_table?.max_budget ?? null;
const hasLimit = maxBudget != null && maxBudget > 0;
const spendPercent = hasLimit ? Math.min((spend / maxBudget) * 100, 100) : 0;
const spendColor = spendPercent >= 90 ? "#f5222d" : spendPercent >= 70 ? "#faad14" : "#52c41a";
const modelSpendData = useMemo(() => {
const raw = (project?.model_spend ?? {}) as Record<string, number>;
return Object.entries(raw)
.map(([model, value]) => ({ model, spend: value }))
.sort((a, b) => b.spend - a.spend);
}, [project?.model_spend]);
if (isLoading) {
return (
<Content
style={{
padding: token.paddingLG,
paddingInline: token.paddingLG * 2,
}}
>
<Flex justify="center" align="center" style={{ minHeight: 300 }}>
<Spin indicator={<LoadingOutlined spin />} size="large" />
</Flex>
</Content>
);
}
if (!project) {
return (
<Content
style={{
padding: token.paddingLG,
paddingInline: token.paddingLG * 2,
}}
>
<Button icon={<ArrowLeftIcon size={16} />} onClick={onBack} type="text" style={{ marginBottom: 16 }} />
<Empty description="Project not found" />
</Content>
);
}
return (
<Content style={{ padding: token.paddingLG, paddingInline: token.paddingLG * 2 }}>
{/* Header */}
<div
style={{
display: "flex",
justifyContent: "space-between",
alignItems: "center",
marginBottom: 24,
}}
>
<div style={{ display: "flex", alignItems: "center", gap: 16 }}>
<Button icon={<ArrowLeftIcon size={16} />} onClick={onBack} type="text" />
<div>
<Flex align="center" gap={8}>
<Title level={2} style={{ margin: 0 }}>
{project.project_alias ?? project.project_id}
</Title>
<Tag color={project.blocked ? "red" : "green"}>{project.blocked ? "Blocked" : "Active"}</Tag>
</Flex>
<Text type="secondary">
ID: <Text copyable>{project.project_id}</Text>
</Text>
</div>
</div>
<Button type="primary" icon={<EditIcon size={16} />} onClick={() => setIsEditModalVisible(true)}>
Edit Project
</Button>
</div>
{/* Project Details */}
<Row style={{ marginBottom: 24 }}>
<Card>
<Descriptions title="Project Details" column={1}>
<Descriptions.Item label="Description">{project.description || "\u2014"}</Descriptions.Item>
<Descriptions.Item label="Created">
{new Date(project.created_at).toLocaleString()}
{project.created_by && (
<Text>
&nbsp;{"by"}&nbsp;
<DefaultProxyAdminTag userId={project.created_by} />
</Text>
)}
</Descriptions.Item>
<Descriptions.Item label="Last Updated">
{new Date(project.updated_at).toLocaleString()}
{project.updated_by && (
<Text>
&nbsp;{"by"}&nbsp;
<DefaultProxyAdminTag userId={project.updated_by} />
</Text>
)}
</Descriptions.Item>
</Descriptions>
</Card>
</Row>
{/* Spend / Budget */}
<Row gutter={[16, 16]} style={{ marginBottom: 24 }}>
<Col xs={24} lg={8}>
<Card
title={
<Flex align="center" gap={8}>
<DollarSignIcon size={16} />
Budget
</Flex>
}
style={{ height: "100%" }}
>
<Flex vertical gap={16}>
<div>
<Text strong style={{ fontSize: 28, lineHeight: 1 }}>
${spend.toFixed(2)}
</Text>
<br />
<Text type="secondary">{hasLimit ? `of $${maxBudget.toFixed(2)} budget` : "No budget limit"}</Text>
</div>
{hasLimit && (
<div>
<Progress percent={Math.round(spendPercent * 10) / 10} strokeColor={spendColor} showInfo={false} />
<Text type="secondary" style={{ fontSize: 12 }}>
{(Math.round(spendPercent * 10) / 10).toFixed(1)}% utilized
</Text>
</div>
)}
</Flex>
</Card>
</Col>
<Col xs={24} lg={16}>
<Card title="Spend by Model" style={{ height: "100%" }}>
{modelSpendData.length > 0 ? (
<BarChart
data={modelSpendData}
index="model"
categories={["spend"]}
colors={["cyan"]}
layout="vertical"
valueFormatter={(value) => `$${value.toFixed(4)}`}
yAxisWidth={140}
showLegend={false}
style={{ height: Math.max(modelSpendData.length * 40, 120) }}
/>
) : (
<Empty description="No model spend recorded yet" image={Empty.PRESENTED_IMAGE_SIMPLE} />
)}
</Card>
</Col>
</Row>
{/* Keys & Team */}
<Row gutter={[16, 16]} style={{ marginBottom: 24 }}>
<Col xs={24} lg={12}>
<ProjectKeysSection projectId={project.project_id} />
</Col>
<Col xs={24} lg={12}>
<Card
title={
<Flex align="center" gap={8}>
<UsersIcon size={16} />
Team
</Flex>
}
style={{ height: "100%" }}
>
{teamInfo ? (
(() => {
const teamBudget = teamInfo.max_budget ?? null;
const teamSpend = teamInfo.spend ?? 0;
const teamHasLimit = teamBudget != null && teamBudget > 0;
const teamPercent = teamHasLimit ? Math.min((teamSpend / teamBudget) * 100, 100) : 0;
const teamColor = teamPercent >= 90 ? "#f5222d" : teamPercent >= 70 ? "#faad14" : "#52c41a";
return (
<Flex vertical gap={12}>
{/* Team name + ID */}
<div>
<Text strong style={{ fontSize: 16 }}>
{teamInfo.team_alias || teamInfo.team_id}
</Text>
<br />
<Text type="secondary" style={{ fontSize: 12 }}>
ID:{" "}
<Text copyable style={{ fontSize: 12 }}>
{teamInfo.team_id}
</Text>
</Text>
</div>
{/* Models */}
<div>
<Text type="secondary" style={{ fontSize: 12, display: "block", marginBottom: 4 }}>
Models
</Text>
{(teamInfo.models?.length ?? 0) > 0 ? (
<Flex wrap="wrap" gap={4} style={{ maxHeight: 60, overflow: "hidden" }}>
{teamInfo.models?.map((m: string) => (
<Tag key={m} style={{ margin: 0 }}>
{m}
</Tag>
))}
</Flex>
) : (
<Text type="secondary">All models</Text>
)}
</div>
{/* Budget + Spend compact */}
<div>
<Flex justify="space-between" align="center" style={{ marginBottom: 2 }}>
<Text type="secondary" style={{ fontSize: 12 }}>
Spend
</Text>
<Text style={{ fontSize: 12 }}>
${teamSpend.toFixed(2)}
{teamHasLimit ? (
<Text type="secondary" style={{ fontSize: 12 }}>
{" "}
/ ${teamBudget.toFixed(2)}
</Text>
) : (
<Text type="secondary" style={{ fontSize: 12 }}>
{" "}
(Unlimited)
</Text>
)}
</Text>
</Flex>
{teamHasLimit && (
<Progress
percent={Math.round(teamPercent * 10) / 10}
strokeColor={teamColor}
size="small"
showInfo={false}
/>
)}
</div>
{/* Members */}
<Flex justify="space-between">
<Text type="secondary" style={{ fontSize: 12 }}>
Members
</Text>
<Text style={{ fontSize: 12 }}>{teamInfo.members_with_roles?.length ?? 0}</Text>
</Flex>
</Flex>
);
})()
) : project.team_id ? (
<Flex justify="center" align="center" style={{ padding: 16 }}>
<Spin indicator={<LoadingOutlined spin />} size="small" />
</Flex>
) : (
<Empty description="No team assigned" image={Empty.PRESENTED_IMAGE_SIMPLE} />
)}
</Card>
</Col>
</Row>
{/* Edit Modal */}
<EditProjectModal isOpen={isEditModalVisible} project={project} onClose={() => setIsEditModalVisible(false)} />
</Content>
);
}

View file

@ -0,0 +1,67 @@
import { useKeys } from "@/app/(dashboard)/hooks/keys/useKeys";
import { LoadingOutlined } from "@ant-design/icons";
import { Card, Flex, Input, Pagination, Spin } from "antd";
import { KeyIcon, SearchIcon } from "lucide-react";
import { useEffect, useState } from "react";
import { ProjectKeysTable } from "./ProjectKeysTable";
interface ProjectKeysSectionProps {
projectId: string;
}
const PAGE_SIZE = 5;
export function ProjectKeysSection({ projectId }: ProjectKeysSectionProps) {
const [page, setPage] = useState(1);
const [keyAlias, setKeyAlias] = useState<string>("");
const { data, isLoading } = useKeys(page, PAGE_SIZE, {
projectID: projectId,
selectedKeyAlias: keyAlias || null,
});
// Reset to page 1 when filter changes
useEffect(() => {
setPage(1);
}, [keyAlias]);
const keys = data?.keys ?? [];
const totalCount = data?.total_count ?? 0;
return (
<Card
title={
<Flex align="center" gap={8}>
<KeyIcon size={16} />
Keys
</Flex>
}
style={{ height: "100%" }}
>
<Flex justify="space-between" align="center" style={{ marginBottom: 12 }}>
<Input
prefix={<SearchIcon size={14} />}
placeholder="Filter by key name..."
style={{ maxWidth: 220 }}
value={keyAlias}
onChange={(e) => setKeyAlias(e.target.value)}
allowClear
size="small"
/>
<Pagination
current={page}
total={totalCount}
pageSize={PAGE_SIZE}
onChange={setPage}
size="small"
showSizeChanger={false}
showTotal={(total) => `${total} keys`}
/>
</Flex>
<ProjectKeysTable
keys={keys}
loading={isLoading ? { indicator: <Spin indicator={<LoadingOutlined spin />} /> } : false}
/>
</Card>
);
}

View file

@ -0,0 +1,58 @@
import { KeyResponse } from "@/components/key_team_helpers/key_list";
import { Empty, Table, Tooltip } from "antd";
import type { ColumnsType } from "antd/es/table";
import type { SpinProps } from "antd";
import DefaultProxyAdminTag from "../common_components/DefaultProxyAdminTag";
interface ProjectKeysTableProps {
keys: KeyResponse[];
loading?: boolean | SpinProps;
}
const columns: ColumnsType<KeyResponse> = [
{
title: "Key Name",
dataIndex: "key_alias",
key: "key_alias",
render: (alias: string | null) => alias || "—",
},
{
title: "Owner",
key: "owner",
render: (_: unknown, record: KeyResponse) => {
const email = record.user?.user_email ?? record.user_id ?? null;
if (!email) return "—";
return (
<Tooltip title={email}>
<DefaultProxyAdminTag userId={email} />
</Tooltip>
);
},
},
{
title: "Created",
dataIndex: "created_at",
key: "created_at",
render: (date: string) => (date ? new Date(date).toLocaleDateString() : "—"),
},
{
title: "Last Active",
dataIndex: "last_active",
key: "last_active",
render: (date: string | null) => (date ? new Date(date).toLocaleDateString() : "Never"),
},
];
export function ProjectKeysTable({ keys, loading }: ProjectKeysTableProps) {
return (
<Table
columns={columns}
dataSource={keys}
rowKey="token"
loading={loading}
pagination={false}
size="small"
locale={{ emptyText: <Empty description="No keys found" image={Empty.PRESENTED_IMAGE_SIMPLE} /> }}
/>
);
}

View file

@ -1,95 +1,39 @@
import { useEffect, useState } from "react";
import { Modal, Form, Button, Typography, message } from "antd";
import { FolderAddOutlined } from "@ant-design/icons";
import {
Alert,
Modal,
Form,
Input,
Select,
Switch,
InputNumber,
Collapse,
Button,
Col,
Flex,
Row,
Space,
Divider,
Typography,
message,
} from "antd";
import { FolderAddOutlined, PlusOutlined, MinusCircleOutlined } from "@ant-design/icons";
import useAuthorized from "@/app/(dashboard)/hooks/useAuthorized";
import { useTeams } from "@/app/(dashboard)/hooks/teams/useTeams";
import { useCreateProject, ProjectCreateParams } from "@/app/(dashboard)/hooks/projects/useCreateProject";
import { Team } from "../../key_team_helpers/key_list";
import { fetchTeamModels } from "../../organisms/create_key_button";
import { getModelDisplayName } from "../../key_team_helpers/fetch_available_models_team_key";
useCreateProject,
ProjectCreateParams,
} from "@/app/(dashboard)/hooks/projects/useCreateProject";
import {
ProjectBaseForm,
ProjectFormValues,
} from "./ProjectBaseForm";
import { buildProjectApiParams } from "./projectFormUtils";
interface CreateProjectModalProps {
isOpen: boolean;
onClose: () => void;
}
export function CreateProjectModal({ isOpen, onClose }: CreateProjectModalProps) {
const [form] = Form.useForm();
const { accessToken, userId, userRole } = useAuthorized();
const { data: teams } = useTeams();
export function CreateProjectModal({
isOpen,
onClose,
}: CreateProjectModalProps) {
const [form] = Form.useForm<ProjectFormValues>();
const createMutation = useCreateProject();
const [selectedTeam, setSelectedTeam] = useState<Team | null>(null);
const [modelsToPick, setModelsToPick] = useState<string[]>([]);
// Fetch team-scoped models when team selection changes
useEffect(() => {
if (userId && userRole && accessToken && selectedTeam) {
fetchTeamModels(userId, userRole, accessToken, selectedTeam.team_id).then((models) => {
const allModels = Array.from(new Set([...(selectedTeam.models ?? []), ...models]));
setModelsToPick(allModels);
});
} else {
setModelsToPick([]);
}
form.setFieldValue("models", []);
}, [selectedTeam, accessToken, userId, userRole, form]);
const handleSubmit = async () => {
try {
const values = await form.validateFields();
// Build model-specific limits from the dynamic form list
const modelRpmLimit: Record<string, number> = {};
const modelTpmLimit: Record<string, number> = {};
for (const entry of values.modelLimits ?? []) {
if (entry.model) {
if (entry.rpm != null) modelRpmLimit[entry.model] = entry.rpm;
if (entry.tpm != null) modelTpmLimit[entry.model] = entry.tpm;
}
}
// Build metadata from the dynamic form list
const metadata: Record<string, unknown> = {};
for (const entry of values.metadata ?? []) {
if (entry.key) metadata[entry.key] = entry.value;
}
const params: ProjectCreateParams = {
project_alias: values.project_alias,
description: values.description,
...buildProjectApiParams(values),
team_id: values.team_id,
models: values.models ?? [],
max_budget: values.max_budget,
blocked: values.isBlocked ?? false,
...(Object.keys(modelRpmLimit).length > 0 && { model_rpm_limit: modelRpmLimit }),
...(Object.keys(modelTpmLimit).length > 0 && { model_tpm_limit: modelTpmLimit }),
...(Object.keys(metadata).length > 0 && { metadata }),
};
createMutation.mutate(params, {
onSuccess: () => {
message.success("Project created successfully");
form.resetFields();
setSelectedTeam(null);
setModelsToPick([]);
onClose();
},
onError: (error) => {
@ -103,16 +47,9 @@ export function CreateProjectModal({ isOpen, onClose }: CreateProjectModalProps)
const handleCancel = () => {
form.resetFields();
setSelectedTeam(null);
setModelsToPick([]);
onClose();
};
const handleTeamChange = (teamId: string) => {
const team = teams?.find((t) => t.team_id === teamId) ?? null;
setSelectedTeam(team);
};
return (
<Modal
title={
@ -123,226 +60,23 @@ export function CreateProjectModal({ isOpen, onClose }: CreateProjectModalProps)
open={isOpen}
onCancel={handleCancel}
width={720}
destroyOnHidden
footer={[
<Button key="cancel" onClick={handleCancel}>
Cancel
</Button>,
<Button key="submit" type="primary" icon={<FolderAddOutlined />} loading={createMutation.isPending} onClick={handleSubmit}>
<Button
key="submit"
type="primary"
icon={<FolderAddOutlined />}
loading={createMutation.isPending}
onClick={handleSubmit}
>
Create Project
</Button>,
]}
>
<Form
form={form}
layout="vertical"
initialValues={{
isBlocked: false,
}}
style={{ marginTop: 24 }}
>
{/* Basic Info */}
<Typography.Text
strong
style={{ fontSize: 13, color: "#374151", textTransform: "uppercase", letterSpacing: "0.05em" }}
>
Basic Information
</Typography.Text>
<Divider style={{ marginTop: 8, marginBottom: 16 }} />
<Row gutter={24}>
<Col span={12}>
<Form.Item
name="project_alias"
label="Project Name"
rules={[{ required: true, message: "Please enter a project name" }]}
>
<Input placeholder="e.g. Customer Support Bot" />
</Form.Item>
</Col>
<Col span={12}>
<Form.Item name="team_id" label="Team" rules={[{ required: true, message: "Please select a team" }]}>
<Select
showSearch
placeholder="Search or select a team"
onChange={handleTeamChange}
allowClear
optionLabelProp="label"
filterOption={(input, option) => {
const team = teams?.find((t) => t.team_id === option?.value);
if (!team) return false;
const search = input.toLowerCase().trim();
return (
(team.team_alias || "").toLowerCase().includes(search) ||
team.team_id.toLowerCase().includes(search)
);
}}
>
{teams?.map((team) => (
<Select.Option key={team.team_id} value={team.team_id} label={team.team_alias || team.team_id}>
<span style={{ fontWeight: 500 }}>{team.team_alias}</span>{" "}
<span style={{ color: "#9ca3af" }}>({team.team_id})</span>
</Select.Option>
))}
</Select>
</Form.Item>
</Col>
</Row>
<Row>
<Col span={24}>
<Form.Item name="description" label="Description">
<Input.TextArea placeholder="Describe the purpose of this project" rows={3} />
</Form.Item>
</Col>
</Row>
<Row>
<Col span={24}>
<Form.Item
name="models"
label="Allowed Models (scoped to selected team's models)"
help={!selectedTeam ? "Select a team first to see available models" : undefined}
>
<Select
mode="multiple"
placeholder={selectedTeam ? "Select models" : "Select a team first"}
disabled={!selectedTeam}
allowClear
maxTagCount="responsive"
onChange={(values) => {
if (values.includes("all-team-models")) {
form.setFieldsValue({ models: ["all-team-models"] });
}
}}
>
<Select.Option key="all-team-models" value="all-team-models">
All Team Models
</Select.Option>
{modelsToPick.map((model) => (
<Select.Option key={model} value={model}>
{getModelDisplayName(model)}
</Select.Option>
))}
</Select>
</Form.Item>
</Col>
</Row>
<Row gutter={24}>
<Col span={12}>
<Form.Item name="max_budget" label="Max Budget (USD)">
<InputNumber prefix="$" style={{ width: "100%" }} placeholder="0.00" min={0} precision={2} />
</Form.Item>
</Col>
</Row>
{/* Advanced Settings */}
<Row>
<Col span={24}>
<Collapse ghost style={{ background: "#f9fafb", borderRadius: 8, border: "1px solid #e5e7eb" }}>
<Collapse.Panel
header={
<Typography.Text strong style={{ color: "#374151" }}>
Advanced Settings
</Typography.Text>
}
key="1"
>
<Flex align="center" gap={12}>
<Typography.Text strong>Block Project</Typography.Text>
<Form.Item name="isBlocked" valuePropName="checked" noStyle>
<Switch />
</Form.Item>
</Flex>
<Form.Item noStyle shouldUpdate={(prev, cur) => prev.isBlocked !== cur.isBlocked}>
{({ getFieldValue }) =>
getFieldValue("isBlocked") ? (
<Alert
banner
type="warning"
showIcon
message="All API requests using keys under this project will be rejected."
style={{ marginTop: 12 }}
/>
) : null
}
</Form.Item>
<Divider />
<Typography.Text strong style={{ display: "block", marginBottom: 12 }}>
Model-Specific Limits
</Typography.Text>
<Form.List name="modelLimits">
{(fields, { add, remove }) => (
<>
{fields.map(({ key, name, ...restField }) => (
<Space key={key} style={{ display: "flex", marginBottom: 8 }} align="baseline">
<Form.Item
{...restField}
name={[name, "model"]}
rules={[{ required: true, message: "Missing model" }]}
>
<Input placeholder="Model name (e.g. gpt-4)" />
</Form.Item>
<Form.Item {...restField} name={[name, "tpm"]}>
<InputNumber placeholder="TPM Limit" min={0} />
</Form.Item>
<Form.Item {...restField} name={[name, "rpm"]}>
<InputNumber placeholder="RPM Limit" min={0} />
</Form.Item>
<MinusCircleOutlined onClick={() => remove(name)} style={{ color: "#ef4444" }} />
</Space>
))}
<Form.Item>
<Button type="dashed" onClick={() => add()} block icon={<PlusOutlined />}>
Add Model Limit
</Button>
</Form.Item>
</>
)}
</Form.List>
<Divider />
<Typography.Text strong style={{ display: "block", marginBottom: 12 }}>
Metadata
</Typography.Text>
<Form.List name="metadata">
{(fields, { add, remove }) => (
<>
{fields.map(({ key, name, ...restField }) => (
<Space key={key} style={{ display: "flex", marginBottom: 8 }} align="baseline">
<Form.Item
{...restField}
name={[name, "key"]}
rules={[{ required: true, message: "Missing key" }]}
>
<Input placeholder="Key" />
</Form.Item>
<Form.Item
{...restField}
name={[name, "value"]}
rules={[{ required: true, message: "Missing value" }]}
>
<Input placeholder="Value" />
</Form.Item>
<MinusCircleOutlined onClick={() => remove(name)} style={{ color: "#ef4444" }} />
</Space>
))}
<Form.Item>
<Button type="dashed" onClick={() => add()} block icon={<PlusOutlined />}>
Add Key-Value Pair
</Button>
</Form.Item>
</>
)}
</Form.List>
</Collapse.Panel>
</Collapse>
</Col>
</Row>
</Form>
<ProjectBaseForm form={form} />
</Modal>
);
}

View file

@ -0,0 +1,126 @@
import { useEffect } from "react";
import { Modal, Form, Button, Typography, message } from "antd";
import { SaveOutlined } from "@ant-design/icons";
import { ProjectResponse } from "@/app/(dashboard)/hooks/projects/useProjects";
import {
useUpdateProject,
ProjectUpdateParams,
} from "@/app/(dashboard)/hooks/projects/useUpdateProject";
import { ProjectBaseForm, ProjectFormValues } from "./ProjectBaseForm";
import { buildProjectApiParams } from "./projectFormUtils";
interface EditProjectModalProps {
isOpen: boolean;
project: ProjectResponse;
onClose: () => void;
onSuccess?: () => void;
}
export function EditProjectModal({
isOpen,
project,
onClose,
onSuccess,
}: EditProjectModalProps) {
const [form] = Form.useForm<ProjectFormValues>();
const updateMutation = useUpdateProject();
// Populate form with existing project data when modal opens
useEffect(() => {
if (isOpen && project) {
// Model limits are stored inside metadata by the backend
const metadataObj = (project.metadata ?? {}) as Record<string, unknown>;
const rpmLimits = (metadataObj.model_rpm_limit ?? {}) as Record<string, number>;
const tpmLimits = (metadataObj.model_tpm_limit ?? {}) as Record<string, number>;
const modelLimits: ProjectFormValues["modelLimits"] = [];
const allLimitModels = new Set([
...Object.keys(rpmLimits),
...Object.keys(tpmLimits),
]);
for (const model of allLimitModels) {
modelLimits.push({
model,
rpm: rpmLimits[model],
tpm: tpmLimits[model],
});
}
// Filter out internal keys from user-facing metadata
const internalKeys = new Set(["model_rpm_limit", "model_tpm_limit"]);
const metadata: ProjectFormValues["metadata"] = [];
for (const [key, value] of Object.entries(metadataObj)) {
if (!internalKeys.has(key)) {
metadata.push({ key, value: String(value) });
}
}
form.setFieldsValue({
project_alias: project.project_alias ?? "",
team_id: project.team_id ?? "",
description: project.description ?? "",
models: project.models ?? [],
max_budget: project.litellm_budget_table?.max_budget ?? undefined,
isBlocked: project.blocked,
modelLimits: modelLimits.length > 0 ? modelLimits : undefined,
metadata: metadata.length > 0 ? metadata : undefined,
});
}
}, [isOpen, project, form]);
const handleSubmit = async () => {
try {
const values = await form.validateFields();
const params: ProjectUpdateParams = {
...buildProjectApiParams(values),
team_id: values.team_id,
};
updateMutation.mutate(
{ projectId: project.project_id, params },
{
onSuccess: () => {
message.success("Project updated successfully");
onSuccess?.();
onClose();
},
onError: (error) => {
message.error(error.message || "Failed to update project");
},
},
);
} catch (error) {
console.error("Validation failed:", error);
}
};
return (
<Modal
title={
<Typography.Text strong style={{ fontSize: 18 }}>
Edit Project
</Typography.Text>
}
open={isOpen}
onCancel={onClose}
width={720}
destroyOnHidden
footer={[
<Button key="cancel" onClick={onClose}>
Cancel
</Button>,
<Button
key="submit"
type="primary"
icon={<SaveOutlined />}
loading={updateMutation.isPending}
onClick={handleSubmit}
>
Save Changes
</Button>,
]}
>
<ProjectBaseForm form={form} />
</Modal>
);
}

View file

@ -0,0 +1,401 @@
import { useEffect, useState } from "react";
import {
Alert,
Col,
Collapse,
Divider,
Flex,
Form,
Input,
InputNumber,
Row,
Select,
Space,
Switch,
Typography,
Button,
} from "antd";
import type { FormInstance } from "antd";
import { PlusOutlined, MinusCircleOutlined } from "@ant-design/icons";
import useAuthorized from "@/app/(dashboard)/hooks/useAuthorized";
import { useTeams } from "@/app/(dashboard)/hooks/teams/useTeams";
import { Team } from "../../key_team_helpers/key_list";
import { fetchTeamModels } from "../../organisms/create_key_button";
import { getModelDisplayName } from "../../key_team_helpers/fetch_available_models_team_key";
export interface ProjectFormValues {
project_alias: string;
team_id: string;
description?: string;
models: string[];
max_budget?: number;
isBlocked: boolean;
modelLimits?: { model: string; tpm?: number; rpm?: number }[];
metadata?: { key: string; value: string }[];
}
interface ProjectBaseFormProps {
form: FormInstance<ProjectFormValues>;
}
export function ProjectBaseForm({
form,
}: ProjectBaseFormProps) {
const { accessToken, userId, userRole } = useAuthorized();
const { data: teams } = useTeams();
const [selectedTeam, setSelectedTeam] = useState<Team | null>(null);
const [modelsToPick, setModelsToPick] = useState<string[]>([]);
// Sync selectedTeam from form value (needed for edit mode pre-fill)
const teamIdValue = Form.useWatch("team_id", form);
useEffect(() => {
if (teamIdValue && teams) {
const team = teams.find((t) => t.team_id === teamIdValue) ?? null;
if (team && team.team_id !== selectedTeam?.team_id) {
setSelectedTeam(team);
}
}
}, [teamIdValue, teams, selectedTeam?.team_id]);
// Fetch team-scoped models when team selection changes
useEffect(() => {
if (userId && userRole && accessToken && selectedTeam) {
fetchTeamModels(userId, userRole, accessToken, selectedTeam.team_id).then(
(models) => {
const allModels = Array.from(
new Set([...(selectedTeam.models ?? []), ...models]),
);
setModelsToPick(allModels);
},
);
} else {
setModelsToPick([]);
}
}, [selectedTeam, accessToken, userId, userRole]);
const handleTeamChange = (teamId: string) => {
const team = teams?.find((t) => t.team_id === teamId) ?? null;
setSelectedTeam(team);
form.setFieldValue("models", []);
};
return (
<Form
form={form}
layout="vertical"
name="project_form"
initialValues={{ isBlocked: false }}
style={{ marginTop: 24 }}
>
{/* Basic Info */}
<Typography.Text
strong
style={{
fontSize: 13,
color: "#374151",
textTransform: "uppercase",
letterSpacing: "0.05em",
}}
>
Basic Information
</Typography.Text>
<Divider style={{ marginTop: 8, marginBottom: 16 }} />
<Row gutter={24}>
<Col span={12}>
<Form.Item
name="project_alias"
label="Project Name"
rules={[
{ required: true, message: "Please enter a project name" },
]}
>
<Input placeholder="e.g. Customer Support Bot" />
</Form.Item>
</Col>
<Col span={12}>
<Form.Item
name="team_id"
label="Team"
rules={[{ required: true, message: "Please select a team" }]}
>
<Select
showSearch
placeholder="Search or select a team"
onChange={handleTeamChange}
allowClear
optionLabelProp="label"
filterOption={(input, option) => {
const team = teams?.find((t) => t.team_id === option?.value);
if (!team) return false;
const search = input.toLowerCase().trim();
return (
(team.team_alias || "").toLowerCase().includes(search) ||
team.team_id.toLowerCase().includes(search)
);
}}
>
{teams?.map((team) => (
<Select.Option
key={team.team_id}
value={team.team_id}
label={team.team_alias || team.team_id}
>
<span style={{ fontWeight: 500 }}>{team.team_alias}</span>{" "}
<span style={{ color: "#9ca3af" }}>({team.team_id})</span>
</Select.Option>
))}
</Select>
</Form.Item>
</Col>
</Row>
<Row>
<Col span={24}>
<Form.Item name="description" label="Description">
<Input.TextArea
placeholder="Describe the purpose of this project"
rows={3}
/>
</Form.Item>
</Col>
</Row>
<Row>
<Col span={24}>
<Form.Item
name="models"
label="Allowed Models (scoped to selected team's models)"
help={
!selectedTeam
? "Select a team first to see available models"
: undefined
}
>
<Select
mode="multiple"
placeholder={
selectedTeam ? "Select models" : "Select a team first"
}
disabled={!selectedTeam}
allowClear
maxTagCount="responsive"
onChange={(values) => {
if (values.includes("all-team-models")) {
form.setFieldsValue({ models: ["all-team-models"] });
}
}}
>
<Select.Option key="all-team-models" value="all-team-models">
All Team Models
</Select.Option>
{modelsToPick.map((model) => (
<Select.Option key={model} value={model}>
{getModelDisplayName(model)}
</Select.Option>
))}
</Select>
</Form.Item>
</Col>
</Row>
<Row gutter={24}>
<Col span={12}>
<Form.Item name="max_budget" label="Max Budget (USD)">
<InputNumber
prefix="$"
style={{ width: "100%" }}
placeholder="0.00"
min={0}
precision={2}
/>
</Form.Item>
</Col>
</Row>
{/* Advanced Settings */}
<Row>
<Col span={24}>
<Collapse
ghost
style={{
background: "#f9fafb",
borderRadius: 8,
border: "1px solid #e5e7eb",
}}
items={[
{
key: "1",
label: (
<Typography.Text strong style={{ color: "#374151" }}>
Advanced Settings
</Typography.Text>
),
children: (
<>
<Flex align="center" gap={12}>
<Typography.Text strong>Block Project</Typography.Text>
<Form.Item name="isBlocked" valuePropName="checked" noStyle>
<Switch />
</Form.Item>
</Flex>
<Form.Item
noStyle
shouldUpdate={(prev, cur) => prev.isBlocked !== cur.isBlocked}
>
{({ getFieldValue }) =>
getFieldValue("isBlocked") ? (
<Alert
banner
type="warning"
showIcon
message="All API requests using keys under this project will be rejected."
style={{ marginTop: 12 }}
/>
) : null
}
</Form.Item>
<Divider />
<Typography.Text
strong
style={{ display: "block", marginBottom: 12 }}
>
Model-Specific Limits
</Typography.Text>
<Form.List name="modelLimits">
{(fields, { add, remove }) => (
<>
{fields.map(({ key, name, ...restField }) => (
<Space
key={key}
style={{ display: "flex", marginBottom: 8 }}
align="baseline"
>
<Form.Item
{...restField}
name={[name, "model"]}
rules={[
{ required: true, message: "Missing model" },
{
validator: (_, value) => {
if (!value) return Promise.resolve();
const all = form.getFieldValue("modelLimits") ?? [];
const dupes = all.filter(
(entry: { model?: string }) => entry?.model === value,
);
if (dupes.length > 1) {
return Promise.reject(new Error("Duplicate model"));
}
return Promise.resolve();
},
},
]}
>
<Input placeholder="Model name (e.g. gpt-4)" />
</Form.Item>
<Form.Item {...restField} name={[name, "tpm"]}>
<InputNumber placeholder="TPM Limit" min={0} />
</Form.Item>
<Form.Item {...restField} name={[name, "rpm"]}>
<InputNumber placeholder="RPM Limit" min={0} />
</Form.Item>
<MinusCircleOutlined
onClick={() => remove(name)}
style={{ color: "#ef4444" }}
/>
</Space>
))}
<Form.Item>
<Button
type="dashed"
onClick={() => add()}
block
icon={<PlusOutlined />}
>
Add Model Limit
</Button>
</Form.Item>
</>
)}
</Form.List>
<Divider />
<Typography.Text
strong
style={{ display: "block", marginBottom: 12 }}
>
Metadata
</Typography.Text>
<Form.List name="metadata">
{(fields, { add, remove }) => (
<>
{fields.map(({ key, name, ...restField }) => (
<Space
key={key}
style={{ display: "flex", marginBottom: 8 }}
align="baseline"
>
<Form.Item
{...restField}
name={[name, "key"]}
rules={[
{ required: true, message: "Missing key" },
{
validator: (_, value) => {
if (!value) return Promise.resolve();
const all = form.getFieldValue("metadata") ?? [];
const dupes = all.filter(
(entry: { key?: string }) => entry?.key === value,
);
if (dupes.length > 1) {
return Promise.reject(new Error("Duplicate key"));
}
return Promise.resolve();
},
},
]}
>
<Input placeholder="Key" />
</Form.Item>
<Form.Item
{...restField}
name={[name, "value"]}
rules={[
{ required: true, message: "Missing value" },
]}
>
<Input placeholder="Value" />
</Form.Item>
<MinusCircleOutlined
onClick={() => remove(name)}
style={{ color: "#ef4444" }}
/>
</Space>
))}
<Form.Item>
<Button
type="dashed"
onClick={() => add()}
block
icon={<PlusOutlined />}
>
Add Key-Value Pair
</Button>
</Form.Item>
</>
)}
</Form.List>
</>
),
},
]}
/>
</Col>
</Row>
</Form>
);
}

View file

@ -0,0 +1,36 @@
import { ProjectFormValues } from "./ProjectBaseForm";
/**
* Transforms ProjectFormValues into the flat API param shape
* shared by both create and update endpoints.
*/
export function buildProjectApiParams(values: ProjectFormValues) {
const modelRpmLimit: Record<string, number> = {};
const modelTpmLimit: Record<string, number> = {};
for (const entry of values.modelLimits ?? []) {
if (entry.model) {
if (entry.rpm != null) modelRpmLimit[entry.model] = entry.rpm;
if (entry.tpm != null) modelTpmLimit[entry.model] = entry.tpm;
}
}
const metadata: Record<string, unknown> = {};
for (const entry of values.metadata ?? []) {
if (entry.key) metadata[entry.key] = entry.value;
}
return {
project_alias: values.project_alias,
description: values.description,
models: values.models ?? [],
max_budget: values.max_budget,
blocked: values.isBlocked ?? false,
...(Object.keys(modelRpmLimit).length > 0 && {
model_rpm_limit: modelRpmLimit,
}),
...(Object.keys(modelTpmLimit).length > 0 && {
model_tpm_limit: modelTpmLimit,
}),
...(Object.keys(metadata).length > 0 && { metadata }),
};
}

View file

@ -1,13 +1,16 @@
import { useProjects, ProjectResponse } from "@/app/(dashboard)/hooks/projects/useProjects";
import { useTeams } from "@/app/(dashboard)/hooks/teams/useTeams";
import { PlusOutlined } from "@ant-design/icons";
import { LoadingOutlined, PlusOutlined } from "@ant-design/icons";
import {
Alert,
Button,
Card,
Flex,
Input,
Layout,
Pagination,
Space,
Spin,
Table,
Tag,
theme,
@ -18,6 +21,7 @@ import type { ColumnsType } from "antd/es/table";
import { LayersIcon, SearchIcon } from "lucide-react";
import { useEffect, useMemo, useState } from "react";
import { CreateProjectModal } from "./ProjectModals/CreateProjectModal";
import { ProjectDetail } from "./ProjectDetailsPage";
const { Title, Text } = Typography;
const { Content } = Layout;
@ -25,8 +29,9 @@ const { Content } = Layout;
export function ProjectsPage() {
const { token } = theme.useToken();
const { data: projects, isLoading } = useProjects();
const { data: teams } = useTeams();
const { data: teams, isLoading: isTeamsLoading } = useTeams();
const [selectedProjectId, setSelectedProjectId] = useState<string | null>(null);
const [isCreateModalVisible, setIsCreateModalVisible] = useState(false);
const [searchText, setSearchText] = useState("");
const [currentPage, setCurrentPage] = useState(1);
@ -74,6 +79,7 @@ export function ProjectsPage() {
ellipsis
className="text-blue-500 bg-blue-50 hover:bg-blue-100 text-xs cursor-pointer"
style={{ fontSize: 14, padding: "1px 8px" }}
onClick={() => setSelectedProjectId(id)}
>
{id}
</Text>
@ -96,8 +102,11 @@ export function ProjectsPage() {
return aAlias.localeCompare(bAlias);
},
render: (_: unknown, record: ProjectResponse) => {
const alias = teamAliasMap.get(record.team_id ?? "");
return alias ?? record.team_id ?? "—";
if (!record.team_id) return "—";
const alias = teamAliasMap.get(record.team_id);
if (alias) return alias;
if (isTeamsLoading) return <Spin indicator={<LoadingOutlined spin />} size="small" />;
return record.team_id;
},
},
{
@ -144,10 +153,25 @@ export function ProjectsPage() {
},
];
if (selectedProjectId) {
return (
<ProjectDetail
projectId={selectedProjectId}
onBack={() => setSelectedProjectId(null)}
/>
);
}
return (
<Content
style={{ padding: token.paddingLG, paddingInline: token.paddingLG * 2 }}
>
<Alert
message="Projects is currently in beta. Features and behavior may change without notice."
type="warning"
showIcon
style={{ marginBottom: 16 }}
/>
<Flex
justify="space-between"
align="center"
@ -184,21 +208,22 @@ export function ProjectsPage() {
onChange={(e) => setSearchText(e.target.value)}
allowClear
/>
<Pagination
current={currentPage}
total={filteredProjects.length}
pageSize={pageSize}
onChange={(page) => setCurrentPage(page)}
size="small"
showTotal={(total) => `${total} projects`}
showSizeChanger={false}
/>
</Flex>
<Table
columns={columns}
dataSource={filteredProjects}
dataSource={filteredProjects.slice((currentPage - 1) * pageSize, currentPage * pageSize)}
rowKey="project_id"
loading={isLoading}
pagination={{
current: currentPage,
pageSize,
total: filteredProjects.length,
onChange: (page) => setCurrentPage(page),
size: "small",
showTotal: (total) => `${total} projects`,
showSizeChanger: false,
}}
pagination={false}
/>
</Card>

View file

@ -0,0 +1,71 @@
import React from "react";
import { render, screen, waitFor } from "@testing-library/react";
import { describe, it, expect, vi, beforeEach } from "vitest";
import AgentsPanel from "./agents";
vi.mock("./networking", () => ({
getAgentsList: vi.fn().mockResolvedValue({ agents: [] }),
deleteAgentCall: vi.fn(),
keyListCall: vi.fn().mockResolvedValue({ keys: [] }),
}));
vi.mock("./agents/add_agent_form", () => ({
default: () => <div data-testid="add-agent-form" />,
}));
vi.mock("./agents/agent_card_grid", () => ({
default: ({ isAdmin }: { isAdmin: boolean }) => (
<div data-testid="agent-card-grid" data-is-admin={String(isAdmin)} />
),
}));
vi.mock("./agents/agent_info", () => ({
default: () => <div data-testid="agent-info" />,
}));
describe("AgentsPanel", () => {
beforeEach(() => {
vi.clearAllMocks();
});
it("should render the Agents panel title", async () => {
render(<AgentsPanel accessToken="test-token" userRole="Admin" />);
expect(screen.getByText("Agents")).toBeInTheDocument();
});
it("should show Add New Agent button for admin users", async () => {
render(<AgentsPanel accessToken="test-token" userRole="Admin" />);
expect(screen.getByText("+ Add New Agent")).toBeInTheDocument();
});
it("should show Add New Agent button for proxy_admin users", async () => {
render(<AgentsPanel accessToken="test-token" userRole="proxy_admin" />);
expect(screen.getByText("+ Add New Agent")).toBeInTheDocument();
});
it("should not show Add New Agent button for internal_user role", async () => {
render(<AgentsPanel accessToken="test-token" userRole="Internal User" />);
expect(screen.queryByText("+ Add New Agent")).not.toBeInTheDocument();
});
it("should not show Add New Agent button for internal_user_viewer role", async () => {
render(<AgentsPanel accessToken="test-token" userRole="Internal Viewer" />);
expect(screen.queryByText("+ Add New Agent")).not.toBeInTheDocument();
});
it("should pass isAdmin=true to AgentCardGrid for admin role", async () => {
render(<AgentsPanel accessToken="test-token" userRole="Admin" />);
await waitFor(() => {
const grid = screen.getByTestId("agent-card-grid");
expect(grid).toHaveAttribute("data-is-admin", "true");
});
});
it("should pass isAdmin=false to AgentCardGrid for internal user role", async () => {
render(<AgentsPanel accessToken="test-token" userRole="Internal User" />);
await waitFor(() => {
const grid = screen.getByTestId("agent-card-grid");
expect(grid).toHaveAttribute("data-is-admin", "false");
});
});
});

View file

@ -141,11 +141,13 @@ const AgentsPanel: React.FC<AgentsPanelProps> = ({ accessToken, userRole }) => {
showIcon
className="mb-3"
/>
<div className="mt-2">
<Button onClick={handleAddAgent} disabled={!accessToken}>
+ Add New Agent
</Button>
</div>
{isAdmin && (
<div className="mt-2">
<Button onClick={handleAddAgent} disabled={!accessToken}>
+ Add New Agent
</Button>
</div>
)}
</div>
{selectedAgentId ? (

View file

@ -37,7 +37,11 @@ const AgentCardGrid: React.FC<AgentCardGridProps> = ({
if (!agentsList || agentsList.length === 0) {
return (
<div className="rounded-lg border border-gray-200 bg-gray-50/50 py-12 text-center">
<p className="text-gray-500">No agents found. Create one to get started.</p>
<p className="text-gray-500">
{isAdmin
? "No agents found. Create one to get started."
: "No agents found. Contact an admin to create agents."}
</p>
</div>
);
}

View file

@ -0,0 +1,62 @@
import React from "react";
import { Select, Spin } from "antd";
import { LoadingOutlined } from "@ant-design/icons";
import { ProjectResponse } from "@/app/(dashboard)/hooks/projects/useProjects";
interface ProjectDropdownProps {
projects?: ProjectResponse[] | null;
value?: string;
onChange?: (value: string) => void;
disabled?: boolean;
loading?: boolean;
/** When set, only show projects belonging to this team */
teamId?: string | null;
}
const ProjectDropdown: React.FC<ProjectDropdownProps> = ({
projects,
value,
onChange,
disabled,
loading,
teamId,
}) => {
const filtered = teamId
? projects?.filter((p) => p.team_id === teamId)
: projects;
return (
<Select
showSearch
placeholder="Search or select a project"
value={value}
onChange={onChange}
disabled={disabled}
loading={loading}
allowClear
notFoundContent={loading ? <Spin indicator={<LoadingOutlined spin />} size="small" /> : undefined}
filterOption={(input, option) => {
if (!option) return false;
const project = filtered?.find((p) => p.project_id === option.key);
if (!project) return false;
const searchTerm = input.toLowerCase().trim();
const alias = (project.project_alias || "").toLowerCase();
const id = (project.project_id || "").toLowerCase();
return alias.includes(searchTerm) || id.includes(searchTerm);
}}
optionFilterProp="children"
>
{!loading &&
filtered?.map((project) => (
<Select.Option key={project.project_id} value={project.project_id}>
<span className="font-medium">{project.project_alias || project.project_id}</span>{" "}
<span className="text-gray-500">({project.project_id})</span>
</Select.Option>
))}
</Select>
);
};
export default ProjectDropdown;

View file

@ -7,10 +7,10 @@ interface TeamDropdownProps {
value?: string;
onChange?: (value: string) => void;
disabled?: boolean;
loading?: boolean;
}
const TeamDropdown: React.FC<TeamDropdownProps> = ({ teams, value, onChange, disabled }) => {
console.log("disabled", disabled);
const TeamDropdown: React.FC<TeamDropdownProps> = ({ teams, value, onChange, disabled, loading }) => {
return (
<Select
showSearch
@ -18,6 +18,7 @@ const TeamDropdown: React.FC<TeamDropdownProps> = ({ teams, value, onChange, dis
value={value}
onChange={onChange}
disabled={disabled}
loading={loading}
allowClear
filterOption={(input, option) => {
if (!option) return false;

View file

@ -30,6 +30,7 @@ export interface KeyResponse {
config: Record<string, unknown>;
user_id: string;
team_id: string | null;
project_id: string | null;
max_parallel_requests: number;
metadata: Record<string, unknown>;
tpm_limit: number;

View file

@ -46,7 +46,7 @@ const MCPPermissionManagement: React.FC<MCPPermissionManagementProps> = ({
}
} else {
form.setFieldValue("allow_all_keys", false);
form.setFieldValue("available_on_public_internet", false);
form.setFieldValue("available_on_public_internet", true);
}
}, [mcpServer, form]);
@ -64,6 +64,7 @@ const MCPPermissionManagement: React.FC<MCPPermissionManagementProps> = ({
}
key="permissions"
className="border-0"
forceRender
>
<div className="space-y-6 pt-4">
<div className="flex items-start justify-between gap-4">
@ -89,17 +90,19 @@ const MCPPermissionManagement: React.FC<MCPPermissionManagementProps> = ({
<div className="flex items-start justify-between gap-4">
<div>
<span className="text-sm font-medium text-gray-700 flex items-center">
Available on Public Internet
<Tooltip title="When enabled, this MCP server is accessible from external/public IPs (e.g., ChatGPT). When disabled, only callers from internal/private networks can access it.">
Internal network only
<Tooltip title="When on, only requests from within your internal network are accepted. Turn off to allow external clients (other clusters, ChatGPT, etc). API key authentication is always required regardless of this setting.">
<InfoCircleOutlined className="ml-2 text-blue-400 hover:text-blue-600 cursor-help" />
</Tooltip>
</span>
<p className="text-sm text-gray-600 mt-1">Enable if this server should be reachable from the public internet.</p>
<p className="text-sm text-gray-600 mt-1">Turn on to restrict access to callers within your internal network only.</p>
</div>
<Form.Item
name="available_on_public_internet"
valuePropName="checked"
initialValue={mcpServer?.available_on_public_internet ?? false}
getValueProps={(value) => ({ checked: !value })}
getValueFromEvent={(checked: boolean) => !checked}
initialValue={true}
className="mb-0"
>
<Switch />

View file

@ -157,9 +157,9 @@ export const mcpServerColumns = (
cell: ({ row }) => {
const isPublic = row.original.available_on_public_internet;
return isPublic ? (
<span className="px-2 py-0.5 bg-green-50 text-green-700 rounded text-xs font-medium">Public</span>
<span className="px-2 py-0.5 bg-green-50 text-green-700 rounded text-xs font-medium">All networks</span>
) : (
<span className="px-2 py-0.5 bg-gray-100 text-gray-600 rounded text-xs font-medium">Internal</span>
<span className="px-2 py-0.5 bg-orange-50 text-orange-700 rounded text-xs font-medium">Internal only</span>
);
},
},

View file

@ -251,20 +251,20 @@ export const MCPServerView: React.FC<MCPServerViewProps> = ({
</div>
</div>
<div>
<Text className="font-medium">Available on Public Internet</Text>
<Text className="font-medium">Network Access</Text>
<div className="flex items-center gap-2">
{mcpServer.available_on_public_internet ? (
<span className="px-2 py-1 bg-green-50 text-green-700 rounded-md text-sm">
Public
All networks
</span>
) : (
<span className="px-2 py-1 bg-gray-100 text-gray-600 rounded-md text-sm">
Internal
<span className="px-2 py-1 bg-orange-50 text-orange-700 rounded-md text-sm">
Internal only
</span>
)}
{mcpServer.available_on_public_internet && (
{!mcpServer.available_on_public_internet && (
<Text className="text-xs text-gray-500">
Accessible from external/public IPs
Restricted to internal network
</Text>
)}
</div>

View file

@ -15,6 +15,7 @@ vi.mock("../networking", () => ({
keyCreateCall: mockKeyCreateCall,
modelAvailableCall: vi.fn().mockResolvedValue({ data: [{ id: "gpt-4" }, { id: "gpt-3.5-turbo" }] }),
getGuardrailsList: vi.fn().mockResolvedValue({ guardrails: [] }),
getPoliciesList: vi.fn().mockResolvedValue({ policies: [] }),
getPromptsList: vi.fn().mockResolvedValue({ prompts: [] }),
proxyBaseUrl: "http://localhost:4000",
getPossibleUserRoles: vi.fn().mockResolvedValue({
@ -27,7 +28,7 @@ vi.mock("../networking", () => ({
soft_budget: null,
}),
fetchMCPAccessGroups: vi.fn().mockResolvedValue([]),
getAgentsList: vi.fn().mockResolvedValue([]),
getAgentsList: vi.fn().mockResolvedValue({ agents: [] }),
}));
vi.mock("../molecules/notifications_manager", () => ({
@ -41,6 +42,20 @@ vi.mock("../molecules/notifications_manager", () => ({
},
}));
vi.mock("@/app/(dashboard)/hooks/projects/useProjects", () => ({
useProjects: vi.fn().mockReturnValue({ data: [], isLoading: false }),
}));
vi.mock("../common_components/ProjectDropdown", () => ({
default: ({ value, onChange }: { value?: string; onChange?: (v: string) => void }) => (
<input
data-testid="project-dropdown"
value={value || ""}
onChange={(e) => onChange?.(e.target.value)}
/>
),
}));
vi.mock("../common_components/AccessGroupSelector", () => ({
default: ({ value = [], onChange }: { value?: string[]; onChange?: (v: string[]) => void }) => (
<input

View file

@ -1,5 +1,6 @@
"use client";
import { keyKeys } from "@/app/(dashboard)/hooks/keys/useKeys";
import { useProjects } from "@/app/(dashboard)/hooks/projects/useProjects";
import useAuthorized from "@/app/(dashboard)/hooks/useAuthorized";
import { formatNumberWithCommas } from "@/utils/dataUtils";
import { InfoCircleOutlined } from "@ant-design/icons";
@ -21,6 +22,7 @@ import PremiumLoggingSettings from "../common_components/PremiumLoggingSettings"
import RateLimitTypeFormItem from "../common_components/RateLimitTypeFormItem";
import RouterSettingsAccordion, { RouterSettingsAccordionValue } from "../common_components/RouterSettingsAccordion";
import TeamDropdown from "../common_components/team_dropdown";
import ProjectDropdown from "../common_components/ProjectDropdown";
import { CreateUserButton } from "../CreateUserButton";
import { getModelDisplayName } from "../key_team_helpers/fetch_available_models_team_key";
import { Team } from "../key_team_helpers/key_list";
@ -143,6 +145,7 @@ export const fetchUserModels = async (
*/
const CreateKey: React.FC<CreateKeyProps> = ({ team, teams, data, addKey }) => {
const { accessToken, userId: userID, userRole, premiumUser } = useAuthorized();
const { data: projects, isLoading: isProjectsLoading } = useProjects();
const queryClient = useQueryClient();
const [form] = Form.useForm();
const [isModalVisible, setIsModalVisible] = useState(false);
@ -157,6 +160,7 @@ const CreateKey: React.FC<CreateKeyProps> = ({ team, teams, data, addKey }) => {
const [promptsList, setPromptsList] = useState<string[]>([]);
const [loggingSettings, setLoggingSettings] = useState<any[]>([]);
const [selectedCreateKeyTeam, setSelectedCreateKeyTeam] = useState<Team | null>(team);
const [selectedProjectId, setSelectedProjectId] = useState<string | null>(null);
const [isCreateUserModalVisible, setIsCreateUserModalVisible] = useState(false);
const [newlyCreatedUserId, setNewlyCreatedUserId] = useState<string | null>(null);
const [possibleUIRoles, setPossibleUIRoles] = useState<Record<string, Record<string, string>>>({});
@ -184,6 +188,7 @@ const CreateKey: React.FC<CreateKeyProps> = ({ team, teams, data, addKey }) => {
setRouterSettings(null);
setRouterSettingsKey((prev) => prev + 1);
setSelectedAgentId(null);
setSelectedProjectId(null);
};
const handleCancel = () => {
@ -200,6 +205,7 @@ const CreateKey: React.FC<CreateKeyProps> = ({ team, teams, data, addKey }) => {
setRouterSettings(null);
setRouterSettingsKey((prev) => prev + 1);
setSelectedAgentId(null);
setSelectedProjectId(null);
};
useEffect(() => {
@ -468,6 +474,14 @@ const CreateKey: React.FC<CreateKeyProps> = ({ team, teams, data, addKey }) => {
};
useEffect(() => {
if (selectedProjectId) {
// When a project is selected, use the project's models
const project = projects?.find((p) => p.project_id === selectedProjectId);
const projectModels = project?.models ?? [];
setModelsToPick(projectModels);
form.setFieldValue("models", []);
return;
}
if (userID && userRole && accessToken) {
fetchTeamModels(userID, userRole, accessToken, selectedCreateKeyTeam?.team_id ?? null).then((models) => {
let allModels = Array.from(new Set([...(selectedCreateKeyTeam?.models ?? []), ...models]));
@ -475,7 +489,22 @@ const CreateKey: React.FC<CreateKeyProps> = ({ team, teams, data, addKey }) => {
});
}
form.setFieldValue("models", []);
}, [selectedCreateKeyTeam, accessToken, userID, userRole]);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [selectedCreateKeyTeam, selectedProjectId, accessToken, userID, userRole]);
// Sync team when project is selected but teams loaded later (race condition)
useEffect(() => {
if (!selectedProjectId || !teams) return;
const project = projects?.find((p) => p.project_id === selectedProjectId);
if (!project?.team_id) return;
// If team is already set correctly, skip
if (selectedCreateKeyTeam?.team_id === project.team_id) return;
const projectTeam = teams.find((t) => t.team_id === project.team_id) || null;
if (projectTeam) {
setSelectedCreateKeyTeam(projectTeam);
form.setFieldValue("team_id", projectTeam.team_id);
}
}, [teams, selectedProjectId, projects]);
// Add a callback function to handle user creation
const handleUserCreated = (userId: string) => {
@ -653,9 +682,40 @@ const CreateKey: React.FC<CreateKeyProps> = ({ team, teams, data, addKey }) => {
>
<TeamDropdown
teams={teams}
disabled={selectedProjectId !== null}
loading={!teams}
onChange={(teamId) => {
const selectedTeam = teams?.find((t) => t.team_id === teamId) || null;
setSelectedCreateKeyTeam(selectedTeam);
setSelectedProjectId(null);
form.setFieldValue("project_id", undefined);
}}
/>
</Form.Item>
<Form.Item
label={
<span>
Project{" "}
<Tooltip title="Assign this key to a project. Selecting a project will lock the team to the project's team.">
<InfoCircleOutlined style={{ marginLeft: "4px" }} />
</Tooltip>
</span>
}
name="project_id"
className="mt-4"
>
<ProjectDropdown
projects={projects}
teamId={selectedCreateKeyTeam?.team_id}
loading={isProjectsLoading || !teams}
onChange={(projectId) => {
if (!projectId) {
setSelectedProjectId(null);
setSelectedCreateKeyTeam(null);
form.setFieldValue("team_id", undefined);
return;
}
setSelectedProjectId(projectId);
}}
/>
</Form.Item>
@ -735,9 +795,11 @@ const CreateKey: React.FC<CreateKeyProps> = ({ team, teams, data, addKey }) => {
}
}}
>
<Option key="all-team-models" value="all-team-models">
All Team Models
</Option>
{!selectedProjectId && (
<Option key="all-team-models" value="all-team-models">
All Team Models
</Option>
)}
{modelsToPick.map((model: string) => (
<Option key={model} value={model}>
{getModelDisplayName(model)}

View file

@ -249,6 +249,11 @@ vi.mock("@/app/(dashboard)/hooks/useTeams", () => ({
})),
}));
// Mock useProjects hook
vi.mock("@/app/(dashboard)/hooks/projects/useProjects", () => ({
useProjects: vi.fn().mockReturnValue({ data: [], isLoading: false }),
}));
// KeyEditView mock: triggers onSubmit with our injected form values
vi.mock("./key_edit_view", async () => {
const React = await import("react");

View file

@ -1,4 +1,5 @@
import GuardrailSelector from "@/components/guardrails/GuardrailSelector";
import { useProjects } from "@/app/(dashboard)/hooks/projects/useProjects";
import PolicySelector from "@/components/policies/PolicySelector";
import { InfoCircleOutlined } from "@ant-design/icons";
import { TextInput, Button as TremorButton } from "@tremor/react";
@ -95,6 +96,15 @@ export function KeyEditView({
const [autoRotationEnabled, setAutoRotationEnabled] = useState<boolean>(keyData.auto_rotate || false);
const [rotationInterval, setRotationInterval] = useState<string>(keyData.rotation_interval || "");
const [isKeySaving, setIsKeySaving] = useState(false);
const { data: projects } = useProjects();
const hasProject = Boolean(keyData.project_id);
const projectDisplay = (() => {
if (!keyData.project_id) return null;
const project = projects?.find((p) => p.project_id === keyData.project_id);
return project?.project_alias
? `${project.project_alias} (${keyData.project_id})`
: keyData.project_id;
})();
useEffect(() => {
const fetchModels = async () => {
@ -590,10 +600,15 @@ export function KeyEditView({
/>
</Form.Item>
<Form.Item label="Team ID" name="team_id">
<Form.Item
label="Team ID"
name="team_id"
help={hasProject ? "Team is locked because this key belongs to a project" : undefined}
>
<Select
placeholder="Select team"
showSearch
disabled={hasProject}
style={{ width: "100%" }}
filterOption={(input, option) => {
const team = teams?.find((t) => t.team_id === option?.value);
@ -601,7 +616,6 @@ export function KeyEditView({
return team.team_alias?.toLowerCase().includes(input.toLowerCase()) ?? false;
}}
>
{/* Only show All Team Models if team has models */}
{teams?.map((team) => (
<Select.Option key={team.team_id} value={team.team_id}>
{`${team.team_alias} (${team.team_id})`}
@ -609,6 +623,11 @@ export function KeyEditView({
))}
</Select>
</Form.Item>
{hasProject && (
<Form.Item label="Project">
<Input value={projectDisplay ?? ""} disabled />
</Form.Item>
)}
<Form.Item label="Logging Settings" name="logging_settings">
<EditLoggingSettings
value={form.getFieldValue("logging_settings")}

View file

@ -1,6 +1,8 @@
import useAuthorized from "@/app/(dashboard)/hooks/useAuthorized";
import { useProjects } from "@/app/(dashboard)/hooks/projects/useProjects";
import useTeams from "@/app/(dashboard)/hooks/useTeams";
import { render, screen, waitFor } from "@testing-library/react";
import { renderWithProviders } from "../../../tests/test-utils";
import { screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { KeyResponse, Team } from "../key_team_helpers/key_list";
@ -14,6 +16,10 @@ vi.mock("@/app/(dashboard)/hooks/useAuthorized", () => ({
default: vi.fn(),
}));
vi.mock("@/app/(dashboard)/hooks/projects/useProjects", () => ({
useProjects: vi.fn().mockReturnValue({ data: [], isLoading: false }),
}));
vi.mock("../networking", () => ({
keyDeleteCall: vi.fn().mockResolvedValue({}),
keyUpdateCall: vi.fn().mockResolvedValue({}),
@ -49,6 +55,7 @@ describe("KeyInfoView", () => {
config: {},
user_id: "default_user_id",
team_id: null,
project_id: null,
max_parallel_requests: 10,
metadata: {
logging: [],
@ -121,7 +128,7 @@ describe("KeyInfoView", () => {
it("should render tags", async () => {
vi.mocked(useAuthorized).mockReturnValue(baseUseAuthorizedMock);
render(
renderWithProviders(
<KeyInfoView
keyData={MOCK_KEY_DATA}
onClose={() => { }}
@ -138,7 +145,7 @@ describe("KeyInfoView", () => {
it("should not render tags in metadata textarea", async () => {
vi.mocked(useAuthorized).mockReturnValue(baseUseAuthorizedMock);
const { container } = render(
const { container } = renderWithProviders(
<KeyInfoView
keyData={MOCK_KEY_DATA}
onClose={() => { }}
@ -168,7 +175,7 @@ describe("KeyInfoView", () => {
});
const keyData = { ...MOCK_KEY_DATA, user_id: "other-user-id" };
render(
renderWithProviders(
<KeyInfoView keyData={keyData} onClose={() => { }} keyId={"test-key-id"} onKeyDataUpdate={() => { }} teams={[]} />,
);
@ -213,7 +220,7 @@ describe("KeyInfoView", () => {
});
const keyData = { ...MOCK_KEY_DATA, team_id: teamId, user_id: "other-user-id" };
render(
renderWithProviders(
<KeyInfoView keyData={keyData} onClose={() => { }} keyId={"test-key-id"} onKeyDataUpdate={() => { }} teams={[]} />,
);
@ -237,7 +244,7 @@ describe("KeyInfoView", () => {
const ownerUserId = "owner-user-id";
const keyData = { ...MOCK_KEY_DATA, user_id: ownerUserId };
render(
renderWithProviders(
<KeyInfoView keyData={keyData} onClose={() => { }} keyId={"test-key-id"} onKeyDataUpdate={() => { }} teams={[]} />,
);
@ -260,7 +267,7 @@ describe("KeyInfoView", () => {
});
const keyData = { ...MOCK_KEY_DATA, user_id: "owner-user-id" };
render(
renderWithProviders(
<KeyInfoView keyData={keyData} onClose={() => { }} keyId={"test-key-id"} onKeyDataUpdate={() => { }} teams={[]} />,
);
@ -284,7 +291,7 @@ describe("KeyInfoView", () => {
const ownerUserId = "internal-viewer-user-id";
const keyData = { ...MOCK_KEY_DATA, user_id: ownerUserId };
render(
renderWithProviders(
<KeyInfoView keyData={keyData} onClose={() => { }} keyId={"test-key-id"} onKeyDataUpdate={() => { }} teams={[]} />,
);
@ -328,7 +335,7 @@ describe("KeyInfoView", () => {
});
const keyData = { ...MOCK_KEY_DATA, team_id: "non-matching-team-id", user_id: "other-user-id" };
render(
renderWithProviders(
<KeyInfoView keyData={keyData} onClose={() => { }} keyId={"test-key-id"} onKeyDataUpdate={() => { }} teams={[]} />,
);
@ -342,7 +349,7 @@ describe("KeyInfoView", () => {
vi.mocked(useAuthorized).mockReturnValue(baseUseAuthorizedMock);
const onCloseMock = vi.fn();
render(
renderWithProviders(
<KeyInfoView
keyData={MOCK_KEY_DATA}
onClose={onCloseMock}
@ -365,7 +372,7 @@ describe("KeyInfoView", () => {
describe("'Edit Settings' button visibility in the Settings tab", () => {
const renderAndOpenSettingsTab = async (keyData = MOCK_KEY_DATA) => {
render(
renderWithProviders(
<KeyInfoView
keyData={keyData}
onClose={() => {}}
@ -474,7 +481,7 @@ describe("KeyInfoView", () => {
},
};
render(
renderWithProviders(
<KeyInfoView
keyData={keyDataWithGuardrails}
onClose={() => { }}
@ -500,7 +507,7 @@ describe("KeyInfoView", () => {
},
};
render(
renderWithProviders(
<KeyInfoView
keyData={keyDataWithPolicies}
onClose={() => { }}
@ -518,7 +525,7 @@ describe("KeyInfoView", () => {
it("should display no key found message when keyData is undefined", async () => {
vi.mocked(useAuthorized).mockReturnValue(baseUseAuthorizedMock);
render(
renderWithProviders(
<KeyInfoView
keyData={undefined}
onClose={() => { }}

View file

@ -1,4 +1,5 @@
import useAuthorized from "@/app/(dashboard)/hooks/useAuthorized";
import { useProjects } from "@/app/(dashboard)/hooks/projects/useProjects";
import useTeams from "@/app/(dashboard)/hooks/useTeams";
import { formatNumberWithCommas } from "@/utils/dataUtils";
import { mapEmptyStringToNull } from "@/utils/keyUpdateUtils";
@ -48,6 +49,7 @@ export default function KeyInfoView({
}: KeyInfoViewProps) {
const { accessToken, userId: userID, userRole, premiumUser } = useAuthorized();
const { teams: teamsData } = useTeams();
const { data: projects } = useProjects();
const [isEditing, setIsEditing] = useState(false);
const [form] = Form.useForm();
const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false);
@ -571,6 +573,20 @@ export default function KeyInfoView({
<Text>{currentKeyData.team_id || "Not Set"}</Text>
</div>
<div>
<Text className="font-medium">Project</Text>
<Text>
{currentKeyData.project_id
? (() => {
const project = projects?.find((p) => p.project_id === currentKeyData.project_id);
return project?.project_alias
? `${project.project_alias} (${currentKeyData.project_id})`
: currentKeyData.project_id;
})()
: "Not Set"}
</Text>
</div>
<div>
<Text className="font-medium">Organization</Text>
<Text>{currentKeyData.organization_id || "Not Set"}</Text>