mirror of
https://github.com/tiennm99/litellm.git
synced 2026-08-17 04:26:20 +00:00
Merge remote-tracking branch 'origin' into litellm_yj_march_18_2026
This commit is contained in:
@@ -140,6 +140,11 @@ LiteLLM is a unified interface for 100+ LLM providers with two main components:
|
||||
- **Check index coverage.** For new or modified queries, check `schema.prisma` for a supporting index. Prefer extending an existing index (e.g. `@@index([a])` → `@@index([a, b])`) over adding a new one, unless it's a `@@unique`. Only add indexes for large/frequent queries.
|
||||
- **Keep schema files in sync.** Apply schema changes to all `schema.prisma` copies (`schema.prisma`, `litellm/proxy/`, `litellm-proxy-extras/`, `litellm-js/spend-logs/` for SpendLogs) with a migration under `litellm-proxy-extras/litellm_proxy_extras/migrations/`.
|
||||
|
||||
### Setup Wizard (`litellm/setup_wizard.py`)
|
||||
- The wizard is implemented as a single `SetupWizard` class with `@staticmethod` methods — keep it that way. No module-level functions except `run_setup_wizard()` (the public entrypoint) and pure helpers (color, ANSI).
|
||||
- Use `litellm.utils.check_valid_key(model, api_key)` for credential validation — never roll a custom completion call.
|
||||
- Do not hardcode provider env-key names or model lists that already exist in the codebase. Add a `test_model` field to each provider entry to drive `check_valid_key`; set it to `None` for providers that can't be validated with a single API key (Azure, Bedrock, Ollama).
|
||||
|
||||
### Enterprise Features
|
||||
- Enterprise-specific code in `enterprise/` directory
|
||||
- Optional features enabled via environment variables
|
||||
|
||||
@@ -83,6 +83,9 @@ os.environ["LANGFUSE_OTEL_HOST"] = "https://cloud.langfuse.com" # EU region
|
||||
# Or use self-hosted instance
|
||||
# os.environ["LANGFUSE_OTEL_HOST"] = "https://my-langfuse.company.com"
|
||||
|
||||
# Optional: Ignore otel context propagation to prevent parent-child relationships with spans from other providers
|
||||
# os.environ["OTEL_IGNORE_CONTEXT_PROPAGATION"] = "true"
|
||||
|
||||
litellm.callbacks = ["langfuse_otel"]
|
||||
```
|
||||
|
||||
@@ -124,6 +127,9 @@ export LANGFUSE_PUBLIC_KEY="pk-lf-..."
|
||||
export LANGFUSE_SECRET_KEY="sk-lf-..."
|
||||
export LANGFUSE_OTEL_HOST="https://us.cloud.langfuse.com" # Default US region
|
||||
# export LANGFUSE_OTEL_HOST="https://otel.my-langfuse.company.com" # custom OTEL endpoint
|
||||
|
||||
# Optional: Ignore otel context propagation to prevent parent-child relationships with spans from other providers
|
||||
# export OTEL_IGNORE_CONTEXT_PROPAGATION="true"
|
||||
```
|
||||
|
||||
2. Setup config.yaml
|
||||
|
||||
@@ -5,11 +5,76 @@ import Image from '@theme/IdealImage';
|
||||
# Getting Started Tutorial
|
||||
|
||||
End-to-End tutorial for LiteLLM Proxy to:
|
||||
- Add an Azure OpenAI model
|
||||
- Make a successful /chat/completion call
|
||||
- Generate a virtual key
|
||||
- Set RPM limit on virtual key
|
||||
- Add an Azure OpenAI model
|
||||
- Make a successful /chat/completion call
|
||||
- Generate a virtual key
|
||||
- Set RPM limit on virtual key
|
||||
|
||||
## Quick Install (Recommended for local / beginners)
|
||||
|
||||
New to LiteLLM? This is the easiest way to get started locally. One command installs LiteLLM and walks you through setup interactively — no config files to write by hand.
|
||||
|
||||
### 1. Install
|
||||
|
||||
```bash
|
||||
curl -fsSL https://raw.githubusercontent.com/BerriAI/litellm/main/scripts/install.sh | sh
|
||||
```
|
||||
|
||||
This detects your OS, installs `litellm[proxy]`, and drops you straight into the setup wizard.
|
||||
|
||||
### 2. Follow the wizard
|
||||
|
||||
```
|
||||
$ litellm --setup
|
||||
|
||||
Welcome to LiteLLM
|
||||
|
||||
Choose your LLM providers
|
||||
○ 1. OpenAI GPT-4o, GPT-4o-mini, o1
|
||||
○ 2. Anthropic Claude Opus, Sonnet, Haiku
|
||||
○ 3. Azure OpenAI GPT-4o via Azure
|
||||
○ 4. Google Gemini Gemini 2.0 Flash, 1.5 Pro
|
||||
○ 5. AWS Bedrock Claude, Llama via AWS
|
||||
○ 6. Ollama Local models
|
||||
|
||||
❯ Provider(s): 1,2
|
||||
|
||||
❯ OpenAI API key: sk-...
|
||||
❯ Anthropic API key: sk-ant-...
|
||||
|
||||
❯ Port [4000]:
|
||||
❯ Master key [auto-generate]:
|
||||
|
||||
✔ Config saved → ./litellm_config.yaml
|
||||
|
||||
❯ Start the proxy now? (Y/n):
|
||||
```
|
||||
|
||||
The wizard walks you through:
|
||||
1. Pick your LLM providers (OpenAI, Anthropic, Azure, Bedrock, Gemini, Ollama)
|
||||
2. Enter API keys for each provider
|
||||
3. Set a port and master key (or accept the defaults)
|
||||
4. Config is saved to `./litellm_config.yaml` and the proxy starts immediately
|
||||
|
||||
### 3. Make a call
|
||||
|
||||
Your proxy is running on `http://0.0.0.0:4000`. Test it:
|
||||
|
||||
```bash
|
||||
curl -X POST 'http://0.0.0.0:4000/chat/completions' \
|
||||
-H 'Content-Type: application/json' \
|
||||
-H 'Authorization: Bearer <your-master-key>' \
|
||||
-d '{
|
||||
"model": "gpt-4o",
|
||||
"messages": [{"role": "user", "content": "Hello!"}]
|
||||
}'
|
||||
```
|
||||
|
||||
:::tip Already have pip installed?
|
||||
You can skip the curl install and run `litellm --setup` directly after `pip install 'litellm[proxy]'`.
|
||||
:::
|
||||
|
||||
---
|
||||
|
||||
## Pre-Requisites
|
||||
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 294 KiB |
@@ -672,6 +672,7 @@ const sidebars = {
|
||||
"mcp_control",
|
||||
"mcp_cost",
|
||||
"mcp_guardrail",
|
||||
"mcp_zero_trust",
|
||||
"mcp_troubleshoot",
|
||||
]
|
||||
},
|
||||
|
||||
Binary file not shown.
Binary file not shown.
+9
@@ -0,0 +1,9 @@
|
||||
-- CreateIndex
|
||||
CREATE INDEX "LiteLLM_TeamTable_organization_id_idx" ON "LiteLLM_TeamTable"("organization_id");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "LiteLLM_TeamTable_team_alias_idx" ON "LiteLLM_TeamTable"("team_alias");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "LiteLLM_TeamTable_created_at_idx" ON "LiteLLM_TeamTable"("created_at");
|
||||
|
||||
@@ -146,6 +146,10 @@ model LiteLLM_TeamTable {
|
||||
litellm_model_table LiteLLM_ModelTable? @relation(fields: [model_id], references: [id])
|
||||
object_permission LiteLLM_ObjectPermissionTable? @relation(fields: [object_permission_id], references: [object_permission_id])
|
||||
projects LiteLLM_ProjectTable[]
|
||||
|
||||
@@index([organization_id])
|
||||
@@index([team_alias])
|
||||
@@index([created_at])
|
||||
}
|
||||
|
||||
// Projects sit between teams and keys for use-case management
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[tool.poetry]
|
||||
name = "litellm-proxy-extras"
|
||||
version = "0.4.57"
|
||||
version = "0.4.58"
|
||||
description = "Additional files for the LiteLLM Proxy. Reduces the size of the main litellm package."
|
||||
authors = ["BerriAI"]
|
||||
readme = "README.md"
|
||||
@@ -22,7 +22,7 @@ requires = ["poetry-core"]
|
||||
build-backend = "poetry.core.masonry.api"
|
||||
|
||||
[tool.commitizen]
|
||||
version = "0.4.57"
|
||||
version = "0.4.58"
|
||||
version_files = [
|
||||
"pyproject.toml:version",
|
||||
"../requirements.txt:litellm-proxy-extras==",
|
||||
|
||||
+9
-3
@@ -1465,9 +1465,15 @@ if TYPE_CHECKING:
|
||||
from .llms.petals.completion.transformation import PetalsConfig as PetalsConfig
|
||||
from .llms.ollama.chat.transformation import OllamaChatConfig as OllamaChatConfig
|
||||
from .llms.ollama.completion.transformation import OllamaConfig as OllamaConfig
|
||||
from .llms.sagemaker.completion.transformation import SagemakerConfig as SagemakerConfig
|
||||
from .llms.sagemaker.chat.transformation import SagemakerChatConfig as SagemakerChatConfig
|
||||
from .llms.sagemaker.nova.transformation import SagemakerNovaConfig as SagemakerNovaConfig
|
||||
from .llms.sagemaker.completion.transformation import (
|
||||
SagemakerConfig as SagemakerConfig,
|
||||
)
|
||||
from .llms.sagemaker.chat.transformation import (
|
||||
SagemakerChatConfig as SagemakerChatConfig,
|
||||
)
|
||||
from .llms.sagemaker.nova.transformation import (
|
||||
SagemakerNovaConfig as SagemakerNovaConfig,
|
||||
)
|
||||
from .llms.cohere.chat.transformation import CohereChatConfig as CohereChatConfig
|
||||
from .llms.anthropic.experimental_pass_through.messages.transformation import (
|
||||
AnthropicMessagesConfig as AnthropicMessagesConfig,
|
||||
|
||||
+6
-2
@@ -17,7 +17,9 @@ if set_verbose is True:
|
||||
"`litellm.set_verbose` is deprecated. Please set `os.environ['LITELLM_LOG'] = 'DEBUG'` for debug logs."
|
||||
)
|
||||
|
||||
_ENABLE_SECRET_REDACTION = os.getenv("LITELLM_DISABLE_REDACT_SECRETS", "").lower() != "true"
|
||||
_ENABLE_SECRET_REDACTION = (
|
||||
os.getenv("LITELLM_DISABLE_REDACT_SECRETS", "").lower() != "true"
|
||||
)
|
||||
|
||||
_REDACTED = "REDACTED"
|
||||
|
||||
@@ -199,7 +201,9 @@ class JsonFormatter(Formatter):
|
||||
json_record[key] = value
|
||||
|
||||
if record.exc_info:
|
||||
json_record["stacktrace"] = record.exc_text or self.formatException(record.exc_info)
|
||||
json_record["stacktrace"] = record.exc_text or self.formatException(
|
||||
record.exc_info
|
||||
)
|
||||
|
||||
return safe_dumps(json_record)
|
||||
|
||||
|
||||
@@ -1189,7 +1189,9 @@ def completion_cost( # noqa: PLR0915
|
||||
and _usage["prompt_tokens_details"] != {}
|
||||
and _usage["prompt_tokens_details"]
|
||||
):
|
||||
prompt_tokens_details = _usage.get("prompt_tokens_details") or {}
|
||||
prompt_tokens_details = (
|
||||
_usage.get("prompt_tokens_details") or {}
|
||||
)
|
||||
cache_read_input_tokens = prompt_tokens_details.get(
|
||||
"cached_tokens", 0
|
||||
)
|
||||
@@ -1515,7 +1517,9 @@ def completion_cost( # noqa: PLR0915
|
||||
if custom_llm_provider == "azure_ai":
|
||||
model_for_additional_costs = request_model_for_cost
|
||||
if completion_response is not None:
|
||||
hidden_params = getattr(completion_response, "_hidden_params", None) or {}
|
||||
hidden_params = (
|
||||
getattr(completion_response, "_hidden_params", None) or {}
|
||||
)
|
||||
hidden_model = hidden_params.get("model") or hidden_params.get(
|
||||
"litellm_model_name"
|
||||
)
|
||||
|
||||
@@ -59,17 +59,14 @@ class FocusDestinationFactory:
|
||||
return {k: v for k, v in resolved.items() if v is not None}
|
||||
if provider == "vantage":
|
||||
resolved = {
|
||||
"api_key": overrides.get("api_key")
|
||||
or os.getenv("VANTAGE_API_KEY"),
|
||||
"api_key": overrides.get("api_key") or os.getenv("VANTAGE_API_KEY"),
|
||||
"integration_token": overrides.get("integration_token")
|
||||
or os.getenv("VANTAGE_INTEGRATION_TOKEN"),
|
||||
"base_url": overrides.get("base_url")
|
||||
or os.getenv("VANTAGE_BASE_URL", "https://api.vantage.sh"),
|
||||
}
|
||||
if not resolved.get("api_key"):
|
||||
raise ValueError(
|
||||
"VANTAGE_API_KEY must be provided for Vantage exports"
|
||||
)
|
||||
raise ValueError("VANTAGE_API_KEY must be provided for Vantage exports")
|
||||
if not resolved.get("integration_token"):
|
||||
raise ValueError(
|
||||
"VANTAGE_INTEGRATION_TOKEN must be provided for Vantage exports"
|
||||
|
||||
@@ -340,9 +340,9 @@ class LangfusePromptManagement(LangFuseLogger, PromptManagementBase, CustomLogge
|
||||
)
|
||||
status_message = str(kwargs.get("exception", "Unknown error"))
|
||||
if standard_logging_object is not None:
|
||||
status_message = standard_logging_object.get(
|
||||
"error_str", None
|
||||
) or status_message
|
||||
status_message = (
|
||||
standard_logging_object.get("error_str", None) or status_message
|
||||
)
|
||||
langfuse_logger_to_use.log_event_on_langfuse(
|
||||
start_time=start_time,
|
||||
end_time=end_time,
|
||||
|
||||
@@ -11,7 +11,7 @@ from litellm.integrations._types.open_inference import (
|
||||
)
|
||||
from litellm.integrations.custom_logger import CustomLogger
|
||||
from litellm.litellm_core_utils.safe_json_dumps import safe_dumps
|
||||
from litellm.secret_managers.main import get_secret_bool
|
||||
from litellm.secret_managers.main import get_secret_bool, str_to_bool
|
||||
from litellm.types.services import ServiceLoggerPayload
|
||||
from litellm.types.utils import (
|
||||
ChatCompletionMessageToolCall,
|
||||
@@ -68,6 +68,7 @@ class OpenTelemetryConfig:
|
||||
service_name: Optional[str] = None
|
||||
deployment_environment: Optional[str] = None
|
||||
model_id: Optional[str] = None
|
||||
ignore_context_propagation: Optional[bool] = None
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
# If endpoint is specified but exporter is still the default "console",
|
||||
@@ -89,6 +90,10 @@ class OpenTelemetryConfig:
|
||||
)
|
||||
if not self.model_id:
|
||||
self.model_id = os.getenv("OTEL_MODEL_ID", self.service_name)
|
||||
if self.ignore_context_propagation is None:
|
||||
self.ignore_context_propagation = str_to_bool(
|
||||
os.getenv("OTEL_IGNORE_CONTEXT_PROPAGATION")
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def from_env(cls):
|
||||
@@ -710,12 +715,7 @@ class OpenTelemetry(CustomLogger):
|
||||
)
|
||||
ctx, parent_span = self._get_span_context(kwargs)
|
||||
|
||||
# CRITICAL FIX: For langfuse_otel, ALWAYS create primary spans
|
||||
# Don't use parent spans from other providers as they cause trace corruption
|
||||
is_langfuse_otel = (
|
||||
hasattr(self, "callback_name") and self.callback_name == "langfuse_otel"
|
||||
)
|
||||
if is_langfuse_otel:
|
||||
if self.config.ignore_context_propagation:
|
||||
parent_span = None # Ignore parent spans from other providers
|
||||
ctx = None
|
||||
|
||||
@@ -1256,12 +1256,7 @@ class OpenTelemetry(CustomLogger):
|
||||
)
|
||||
_parent_context, parent_otel_span = self._get_span_context(kwargs)
|
||||
|
||||
# CRITICAL FIX: For langfuse_otel, ALWAYS create primary spans
|
||||
# Don't use parent spans from other providers as they cause trace corruption
|
||||
is_langfuse_otel = (
|
||||
hasattr(self, "callback_name") and self.callback_name == "langfuse_otel"
|
||||
)
|
||||
if is_langfuse_otel:
|
||||
if self.config.ignore_context_propagation:
|
||||
parent_otel_span = None # Ignore parent spans from other providers
|
||||
_parent_context = None
|
||||
|
||||
|
||||
@@ -83,7 +83,9 @@ class VantageLogger(FocusLogger):
|
||||
|
||||
verbose_logger.debug(
|
||||
"VantageLogger initialized (integration_token=%s)",
|
||||
resolved_token[:4] + "***" if resolved_token and len(resolved_token) > 4 else "***",
|
||||
resolved_token[:4] + "***"
|
||||
if resolved_token and len(resolved_token) > 4
|
||||
else "***",
|
||||
)
|
||||
|
||||
async def initialize_focus_export_job(self) -> None:
|
||||
@@ -128,9 +130,7 @@ class VantageLogger(FocusLogger):
|
||||
callback_type=VantageLogger
|
||||
)
|
||||
if not vantage_loggers:
|
||||
verbose_logger.debug(
|
||||
"No Vantage logger registered; skipping scheduler"
|
||||
)
|
||||
verbose_logger.debug("No Vantage logger registered; skipping scheduler")
|
||||
return
|
||||
|
||||
vantage_logger = cast(VantageLogger, vantage_loggers[0])
|
||||
|
||||
@@ -26,7 +26,9 @@ if custom_cache_dir:
|
||||
else:
|
||||
cache_dir = filename
|
||||
|
||||
os.environ["TIKTOKEN_CACHE_DIR"] = cache_dir # use local copy of tiktoken b/c of - https://github.com/BerriAI/litellm/issues/1071
|
||||
os.environ[
|
||||
"TIKTOKEN_CACHE_DIR"
|
||||
] = cache_dir # use local copy of tiktoken b/c of - https://github.com/BerriAI/litellm/issues/1071
|
||||
|
||||
import tiktoken
|
||||
import time
|
||||
@@ -48,4 +50,3 @@ for attempt in range(_max_retries):
|
||||
# Exponential backoff with jitter to reduce collision probability
|
||||
delay = _retry_delay * (2**attempt) + random.uniform(0, 0.1)
|
||||
time.sleep(delay)
|
||||
|
||||
|
||||
@@ -352,9 +352,9 @@ class Logging(LiteLLMLoggingBaseClass):
|
||||
)
|
||||
self.function_id = function_id
|
||||
self.streaming_chunks: List[Any] = [] # for generating complete stream response
|
||||
self.sync_streaming_chunks: List[Any] = (
|
||||
[]
|
||||
) # for generating complete stream response
|
||||
self.sync_streaming_chunks: List[
|
||||
Any
|
||||
] = [] # for generating complete stream response
|
||||
self.log_raw_request_response = log_raw_request_response
|
||||
|
||||
# Initialize dynamic callbacks
|
||||
@@ -782,9 +782,9 @@ class Logging(LiteLLMLoggingBaseClass):
|
||||
prompt_spec=prompt_spec,
|
||||
dynamic_callback_params=dynamic_callback_params,
|
||||
):
|
||||
self.model_call_details["prompt_integration"] = (
|
||||
logger.__class__.__name__
|
||||
)
|
||||
self.model_call_details[
|
||||
"prompt_integration"
|
||||
] = logger.__class__.__name__
|
||||
return logger
|
||||
except Exception:
|
||||
# If check fails, continue to next logger
|
||||
@@ -852,9 +852,9 @@ class Logging(LiteLLMLoggingBaseClass):
|
||||
if anthropic_cache_control_logger := AnthropicCacheControlHook.get_custom_logger_for_anthropic_cache_control_hook(
|
||||
non_default_params
|
||||
):
|
||||
self.model_call_details["prompt_integration"] = (
|
||||
anthropic_cache_control_logger.__class__.__name__
|
||||
)
|
||||
self.model_call_details[
|
||||
"prompt_integration"
|
||||
] = anthropic_cache_control_logger.__class__.__name__
|
||||
return anthropic_cache_control_logger
|
||||
|
||||
#########################################################
|
||||
@@ -866,9 +866,9 @@ class Logging(LiteLLMLoggingBaseClass):
|
||||
internal_usage_cache=None,
|
||||
llm_router=None,
|
||||
)
|
||||
self.model_call_details["prompt_integration"] = (
|
||||
vector_store_custom_logger.__class__.__name__
|
||||
)
|
||||
self.model_call_details[
|
||||
"prompt_integration"
|
||||
] = vector_store_custom_logger.__class__.__name__
|
||||
# Add to global callbacks so post-call hooks are invoked
|
||||
if (
|
||||
vector_store_custom_logger
|
||||
@@ -928,9 +928,9 @@ class Logging(LiteLLMLoggingBaseClass):
|
||||
model
|
||||
): # if model name was changes pre-call, overwrite the initial model call name with the new one
|
||||
self.model_call_details["model"] = model
|
||||
self.model_call_details["litellm_params"]["api_base"] = (
|
||||
self._get_masked_api_base(additional_args.get("api_base", ""))
|
||||
)
|
||||
self.model_call_details["litellm_params"][
|
||||
"api_base"
|
||||
] = self._get_masked_api_base(additional_args.get("api_base", ""))
|
||||
|
||||
def pre_call(self, input, api_key, model=None, additional_args={}): # noqa: PLR0915
|
||||
# Log the exact input to the LLM API
|
||||
@@ -959,10 +959,10 @@ class Logging(LiteLLMLoggingBaseClass):
|
||||
try:
|
||||
# [Non-blocking Extra Debug Information in metadata]
|
||||
if turn_off_message_logging is True:
|
||||
_metadata["raw_request"] = (
|
||||
"redacted by litellm. \
|
||||
_metadata[
|
||||
"raw_request"
|
||||
] = "redacted by litellm. \
|
||||
'litellm.turn_off_message_logging=True'"
|
||||
)
|
||||
else:
|
||||
curl_command = self._get_request_curl_command(
|
||||
api_base=additional_args.get("api_base", ""),
|
||||
@@ -973,34 +973,34 @@ class Logging(LiteLLMLoggingBaseClass):
|
||||
|
||||
_metadata["raw_request"] = str(curl_command)
|
||||
# split up, so it's easier to parse in the UI
|
||||
self.model_call_details["raw_request_typed_dict"] = (
|
||||
RawRequestTypedDict(
|
||||
raw_request_api_base=str(
|
||||
additional_args.get("api_base") or ""
|
||||
),
|
||||
raw_request_body=self._get_raw_request_body(
|
||||
additional_args.get("complete_input_dict", {})
|
||||
),
|
||||
# NOTE: setting ignore_sensitive_headers to True will cause
|
||||
# the Authorization header to be leaked when calls to the health
|
||||
# endpoint are made and fail.
|
||||
raw_request_headers=self._get_masked_headers(
|
||||
additional_args.get("headers", {}) or {},
|
||||
),
|
||||
error=None,
|
||||
)
|
||||
self.model_call_details[
|
||||
"raw_request_typed_dict"
|
||||
] = RawRequestTypedDict(
|
||||
raw_request_api_base=str(
|
||||
additional_args.get("api_base") or ""
|
||||
),
|
||||
raw_request_body=self._get_raw_request_body(
|
||||
additional_args.get("complete_input_dict", {})
|
||||
),
|
||||
# NOTE: setting ignore_sensitive_headers to True will cause
|
||||
# the Authorization header to be leaked when calls to the health
|
||||
# endpoint are made and fail.
|
||||
raw_request_headers=self._get_masked_headers(
|
||||
additional_args.get("headers", {}) or {},
|
||||
),
|
||||
error=None,
|
||||
)
|
||||
except Exception as e:
|
||||
self.model_call_details["raw_request_typed_dict"] = (
|
||||
RawRequestTypedDict(
|
||||
error=str(e),
|
||||
)
|
||||
self.model_call_details[
|
||||
"raw_request_typed_dict"
|
||||
] = RawRequestTypedDict(
|
||||
error=str(e),
|
||||
)
|
||||
_metadata["raw_request"] = (
|
||||
"Unable to Log \
|
||||
_metadata[
|
||||
"raw_request"
|
||||
] = "Unable to Log \
|
||||
raw request: {}".format(
|
||||
str(e)
|
||||
)
|
||||
str(e)
|
||||
)
|
||||
if getattr(self, "logger_fn", None) and callable(self.logger_fn):
|
||||
try:
|
||||
@@ -1301,13 +1301,13 @@ class Logging(LiteLLMLoggingBaseClass):
|
||||
for callback in callbacks:
|
||||
try:
|
||||
if isinstance(callback, CustomLogger):
|
||||
response: Optional[MCPPostCallResponseObject] = (
|
||||
await callback.async_post_mcp_tool_call_hook(
|
||||
kwargs=kwargs,
|
||||
response_obj=post_mcp_tool_call_response_obj,
|
||||
start_time=start_time,
|
||||
end_time=end_time,
|
||||
)
|
||||
response: Optional[
|
||||
MCPPostCallResponseObject
|
||||
] = await callback.async_post_mcp_tool_call_hook(
|
||||
kwargs=kwargs,
|
||||
response_obj=post_mcp_tool_call_response_obj,
|
||||
start_time=start_time,
|
||||
end_time=end_time,
|
||||
)
|
||||
######################################################################
|
||||
# if any of the callbacks modify the response, use the modified response
|
||||
@@ -1502,9 +1502,9 @@ class Logging(LiteLLMLoggingBaseClass):
|
||||
verbose_logger.debug(
|
||||
f"response_cost_failure_debug_information: {debug_info}"
|
||||
)
|
||||
self.model_call_details["response_cost_failure_debug_information"] = (
|
||||
debug_info
|
||||
)
|
||||
self.model_call_details[
|
||||
"response_cost_failure_debug_information"
|
||||
] = debug_info
|
||||
return None
|
||||
|
||||
try:
|
||||
@@ -1530,9 +1530,9 @@ class Logging(LiteLLMLoggingBaseClass):
|
||||
verbose_logger.debug(
|
||||
f"response_cost_failure_debug_information: {debug_info}"
|
||||
)
|
||||
self.model_call_details["response_cost_failure_debug_information"] = (
|
||||
debug_info
|
||||
)
|
||||
self.model_call_details[
|
||||
"response_cost_failure_debug_information"
|
||||
] = debug_info
|
||||
|
||||
return None
|
||||
|
||||
@@ -1688,9 +1688,9 @@ class Logging(LiteLLMLoggingBaseClass):
|
||||
result=logging_result
|
||||
)
|
||||
|
||||
self.model_call_details["standard_logging_object"] = (
|
||||
self._build_standard_logging_payload(logging_result, start_time, end_time)
|
||||
)
|
||||
self.model_call_details[
|
||||
"standard_logging_object"
|
||||
] = self._build_standard_logging_payload(logging_result, start_time, end_time)
|
||||
|
||||
if (
|
||||
standard_logging_payload := self.model_call_details.get(
|
||||
@@ -1768,9 +1768,9 @@ class Logging(LiteLLMLoggingBaseClass):
|
||||
end_time = datetime.datetime.now()
|
||||
if self.completion_start_time is None:
|
||||
self.completion_start_time = end_time
|
||||
self.model_call_details["completion_start_time"] = (
|
||||
self.completion_start_time
|
||||
)
|
||||
self.model_call_details[
|
||||
"completion_start_time"
|
||||
] = self.completion_start_time
|
||||
|
||||
self.model_call_details["log_event_type"] = "successful_api_call"
|
||||
self.model_call_details["end_time"] = end_time
|
||||
@@ -1807,10 +1807,10 @@ class Logging(LiteLLMLoggingBaseClass):
|
||||
end_time=end_time,
|
||||
)
|
||||
elif isinstance(result, dict) or isinstance(result, list):
|
||||
self.model_call_details["standard_logging_object"] = (
|
||||
self._build_standard_logging_payload(
|
||||
result, start_time, end_time
|
||||
)
|
||||
self.model_call_details[
|
||||
"standard_logging_object"
|
||||
] = self._build_standard_logging_payload(
|
||||
result, start_time, end_time
|
||||
)
|
||||
if (
|
||||
standard_logging_payload := self.model_call_details.get(
|
||||
@@ -1819,9 +1819,9 @@ class Logging(LiteLLMLoggingBaseClass):
|
||||
) is not None:
|
||||
emit_standard_logging_payload(standard_logging_payload)
|
||||
elif standard_logging_object is not None:
|
||||
self.model_call_details["standard_logging_object"] = (
|
||||
standard_logging_object
|
||||
)
|
||||
self.model_call_details[
|
||||
"standard_logging_object"
|
||||
] = standard_logging_object
|
||||
else:
|
||||
self.model_call_details["response_cost"] = None
|
||||
|
||||
@@ -1979,17 +1979,17 @@ class Logging(LiteLLMLoggingBaseClass):
|
||||
verbose_logger.debug(
|
||||
"Logging Details LiteLLM-Success Call streaming complete"
|
||||
)
|
||||
self.model_call_details["complete_streaming_response"] = (
|
||||
complete_streaming_response
|
||||
)
|
||||
self.model_call_details["response_cost"] = (
|
||||
self._response_cost_calculator(result=complete_streaming_response)
|
||||
)
|
||||
self.model_call_details[
|
||||
"complete_streaming_response"
|
||||
] = complete_streaming_response
|
||||
self.model_call_details[
|
||||
"response_cost"
|
||||
] = self._response_cost_calculator(result=complete_streaming_response)
|
||||
## STANDARDIZED LOGGING PAYLOAD
|
||||
self.model_call_details["standard_logging_object"] = (
|
||||
self._build_standard_logging_payload(
|
||||
complete_streaming_response, start_time, end_time
|
||||
)
|
||||
self.model_call_details[
|
||||
"standard_logging_object"
|
||||
] = self._build_standard_logging_payload(
|
||||
complete_streaming_response, start_time, end_time
|
||||
)
|
||||
if (
|
||||
standard_logging_payload := self.model_call_details.get(
|
||||
@@ -2323,10 +2323,10 @@ class Logging(LiteLLMLoggingBaseClass):
|
||||
)
|
||||
else:
|
||||
if self.stream and complete_streaming_response:
|
||||
self.model_call_details["complete_response"] = (
|
||||
self.model_call_details.get(
|
||||
"complete_streaming_response", {}
|
||||
)
|
||||
self.model_call_details[
|
||||
"complete_response"
|
||||
] = self.model_call_details.get(
|
||||
"complete_streaming_response", {}
|
||||
)
|
||||
result = self.model_call_details["complete_response"]
|
||||
openMeterLogger.log_success_event(
|
||||
@@ -2350,10 +2350,10 @@ class Logging(LiteLLMLoggingBaseClass):
|
||||
)
|
||||
else:
|
||||
if self.stream and complete_streaming_response:
|
||||
self.model_call_details["complete_response"] = (
|
||||
self.model_call_details.get(
|
||||
"complete_streaming_response", {}
|
||||
)
|
||||
self.model_call_details[
|
||||
"complete_response"
|
||||
] = self.model_call_details.get(
|
||||
"complete_streaming_response", {}
|
||||
)
|
||||
result = self.model_call_details["complete_response"]
|
||||
|
||||
@@ -2492,9 +2492,9 @@ class Logging(LiteLLMLoggingBaseClass):
|
||||
if complete_streaming_response is not None:
|
||||
print_verbose("Async success callbacks: Got a complete streaming response")
|
||||
|
||||
self.model_call_details["async_complete_streaming_response"] = (
|
||||
complete_streaming_response
|
||||
)
|
||||
self.model_call_details[
|
||||
"async_complete_streaming_response"
|
||||
] = complete_streaming_response
|
||||
|
||||
try:
|
||||
if self.model_call_details.get("cache_hit", False) is True:
|
||||
@@ -2505,10 +2505,10 @@ class Logging(LiteLLMLoggingBaseClass):
|
||||
model_call_details=self.model_call_details
|
||||
)
|
||||
# base_model defaults to None if not set on model_info
|
||||
self.model_call_details["response_cost"] = (
|
||||
self._response_cost_calculator(
|
||||
result=complete_streaming_response
|
||||
)
|
||||
self.model_call_details[
|
||||
"response_cost"
|
||||
] = self._response_cost_calculator(
|
||||
result=complete_streaming_response
|
||||
)
|
||||
|
||||
verbose_logger.debug(
|
||||
@@ -2521,10 +2521,10 @@ class Logging(LiteLLMLoggingBaseClass):
|
||||
self.model_call_details["response_cost"] = None
|
||||
|
||||
## STANDARDIZED LOGGING PAYLOAD
|
||||
self.model_call_details["standard_logging_object"] = (
|
||||
self._build_standard_logging_payload(
|
||||
complete_streaming_response, start_time, end_time
|
||||
)
|
||||
self.model_call_details[
|
||||
"standard_logging_object"
|
||||
] = self._build_standard_logging_payload(
|
||||
complete_streaming_response, start_time, end_time
|
||||
)
|
||||
|
||||
# print standard logging payload
|
||||
@@ -2551,9 +2551,9 @@ class Logging(LiteLLMLoggingBaseClass):
|
||||
# _success_handler_helper_fn
|
||||
if self.model_call_details.get("standard_logging_object") is None:
|
||||
## STANDARDIZED LOGGING PAYLOAD
|
||||
self.model_call_details["standard_logging_object"] = (
|
||||
self._build_standard_logging_payload(result, start_time, end_time)
|
||||
)
|
||||
self.model_call_details[
|
||||
"standard_logging_object"
|
||||
] = self._build_standard_logging_payload(result, start_time, end_time)
|
||||
|
||||
# print standard logging payload
|
||||
if (
|
||||
@@ -2796,18 +2796,18 @@ class Logging(LiteLLMLoggingBaseClass):
|
||||
|
||||
## STANDARDIZED LOGGING PAYLOAD
|
||||
|
||||
self.model_call_details["standard_logging_object"] = (
|
||||
get_standard_logging_object_payload(
|
||||
kwargs=self.model_call_details,
|
||||
init_response_obj={},
|
||||
start_time=start_time,
|
||||
end_time=end_time,
|
||||
logging_obj=self,
|
||||
status="failure",
|
||||
error_str=str(exception),
|
||||
original_exception=exception,
|
||||
standard_built_in_tools_params=self.standard_built_in_tools_params,
|
||||
)
|
||||
self.model_call_details[
|
||||
"standard_logging_object"
|
||||
] = get_standard_logging_object_payload(
|
||||
kwargs=self.model_call_details,
|
||||
init_response_obj={},
|
||||
start_time=start_time,
|
||||
end_time=end_time,
|
||||
logging_obj=self,
|
||||
status="failure",
|
||||
error_str=str(exception),
|
||||
original_exception=exception,
|
||||
standard_built_in_tools_params=self.standard_built_in_tools_params,
|
||||
)
|
||||
return start_time, end_time
|
||||
|
||||
@@ -3774,9 +3774,9 @@ def _init_custom_logger_compatible_class( # noqa: PLR0915
|
||||
service_name=arize_config.project_name,
|
||||
)
|
||||
|
||||
os.environ["OTEL_EXPORTER_OTLP_TRACES_HEADERS"] = (
|
||||
f"space_id={arize_config.space_key or arize_config.space_id},api_key={arize_config.api_key}"
|
||||
)
|
||||
os.environ[
|
||||
"OTEL_EXPORTER_OTLP_TRACES_HEADERS"
|
||||
] = f"space_id={arize_config.space_key or arize_config.space_id},api_key={arize_config.api_key}"
|
||||
for callback in _in_memory_loggers:
|
||||
if (
|
||||
isinstance(callback, ArizeLogger)
|
||||
@@ -3802,13 +3802,13 @@ def _init_custom_logger_compatible_class( # noqa: PLR0915
|
||||
existing_attrs = os.environ.get("OTEL_RESOURCE_ATTRIBUTES", "")
|
||||
# Add openinference.project.name attribute
|
||||
if existing_attrs:
|
||||
os.environ["OTEL_RESOURCE_ATTRIBUTES"] = (
|
||||
f"{existing_attrs},openinference.project.name={arize_phoenix_config.project_name}"
|
||||
)
|
||||
os.environ[
|
||||
"OTEL_RESOURCE_ATTRIBUTES"
|
||||
] = f"{existing_attrs},openinference.project.name={arize_phoenix_config.project_name}"
|
||||
else:
|
||||
os.environ["OTEL_RESOURCE_ATTRIBUTES"] = (
|
||||
f"openinference.project.name={arize_phoenix_config.project_name}"
|
||||
)
|
||||
os.environ[
|
||||
"OTEL_RESOURCE_ATTRIBUTES"
|
||||
] = f"openinference.project.name={arize_phoenix_config.project_name}"
|
||||
|
||||
# Set Phoenix project name from environment variable
|
||||
phoenix_project_name = os.environ.get("PHOENIX_PROJECT_NAME", None)
|
||||
@@ -3816,19 +3816,19 @@ def _init_custom_logger_compatible_class( # noqa: PLR0915
|
||||
existing_attrs = os.environ.get("OTEL_RESOURCE_ATTRIBUTES", "")
|
||||
# Add openinference.project.name attribute
|
||||
if existing_attrs:
|
||||
os.environ["OTEL_RESOURCE_ATTRIBUTES"] = (
|
||||
f"{existing_attrs},openinference.project.name={phoenix_project_name}"
|
||||
)
|
||||
os.environ[
|
||||
"OTEL_RESOURCE_ATTRIBUTES"
|
||||
] = f"{existing_attrs},openinference.project.name={phoenix_project_name}"
|
||||
else:
|
||||
os.environ["OTEL_RESOURCE_ATTRIBUTES"] = (
|
||||
f"openinference.project.name={phoenix_project_name}"
|
||||
)
|
||||
os.environ[
|
||||
"OTEL_RESOURCE_ATTRIBUTES"
|
||||
] = f"openinference.project.name={phoenix_project_name}"
|
||||
|
||||
# auth can be disabled on local deployments of arize phoenix
|
||||
if arize_phoenix_config.otlp_auth_headers is not None:
|
||||
os.environ["OTEL_EXPORTER_OTLP_TRACES_HEADERS"] = (
|
||||
arize_phoenix_config.otlp_auth_headers
|
||||
)
|
||||
os.environ[
|
||||
"OTEL_EXPORTER_OTLP_TRACES_HEADERS"
|
||||
] = arize_phoenix_config.otlp_auth_headers
|
||||
|
||||
for callback in _in_memory_loggers:
|
||||
if (
|
||||
@@ -3907,7 +3907,9 @@ def _init_custom_logger_compatible_class( # noqa: PLR0915
|
||||
from litellm.integrations.focus.focus_logger import FocusLogger
|
||||
|
||||
for callback in _in_memory_loggers:
|
||||
if type(callback) is FocusLogger: # exact match; exclude subclasses like VantageLogger
|
||||
if (
|
||||
type(callback) is FocusLogger
|
||||
): # exact match; exclude subclasses like VantageLogger
|
||||
return callback # type: ignore
|
||||
focus_logger = FocusLogger()
|
||||
_in_memory_loggers.append(focus_logger)
|
||||
@@ -4013,9 +4015,9 @@ def _init_custom_logger_compatible_class( # noqa: PLR0915
|
||||
exporter="otlp_http",
|
||||
endpoint="https://langtrace.ai/api/trace",
|
||||
)
|
||||
os.environ["OTEL_EXPORTER_OTLP_TRACES_HEADERS"] = (
|
||||
f"api_key={os.getenv('LANGTRACE_API_KEY')}"
|
||||
)
|
||||
os.environ[
|
||||
"OTEL_EXPORTER_OTLP_TRACES_HEADERS"
|
||||
] = f"api_key={os.getenv('LANGTRACE_API_KEY')}"
|
||||
for callback in _in_memory_loggers:
|
||||
if (
|
||||
isinstance(callback, OpenTelemetry)
|
||||
@@ -4289,7 +4291,9 @@ def get_custom_logger_compatible_class( # noqa: PLR0915
|
||||
from litellm.integrations.focus.focus_logger import FocusLogger
|
||||
|
||||
for callback in _in_memory_loggers:
|
||||
if type(callback) is FocusLogger: # exact match; exclude subclasses like VantageLogger
|
||||
if (
|
||||
type(callback) is FocusLogger
|
||||
): # exact match; exclude subclasses like VantageLogger
|
||||
return callback
|
||||
elif logging_integration == "vantage":
|
||||
from litellm.integrations.vantage.vantage_logger import VantageLogger
|
||||
@@ -4937,10 +4941,10 @@ class StandardLoggingPayloadSetup:
|
||||
for key in StandardLoggingHiddenParams.__annotations__.keys():
|
||||
if key in hidden_params:
|
||||
if key == "additional_headers":
|
||||
clean_hidden_params["additional_headers"] = (
|
||||
StandardLoggingPayloadSetup.get_additional_headers(
|
||||
hidden_params[key]
|
||||
)
|
||||
clean_hidden_params[
|
||||
"additional_headers"
|
||||
] = StandardLoggingPayloadSetup.get_additional_headers(
|
||||
hidden_params[key]
|
||||
)
|
||||
else:
|
||||
clean_hidden_params[key] = hidden_params[key] # type: ignore
|
||||
@@ -5579,9 +5583,9 @@ def scrub_sensitive_keys_in_metadata(litellm_params: Optional[dict]):
|
||||
):
|
||||
for k, v in metadata["user_api_key_metadata"].items():
|
||||
if k == "logging": # prevent logging user logging keys
|
||||
cleaned_user_api_key_metadata[k] = (
|
||||
"scrubbed_by_litellm_for_sensitive_keys"
|
||||
)
|
||||
cleaned_user_api_key_metadata[
|
||||
k
|
||||
] = "scrubbed_by_litellm_for_sensitive_keys"
|
||||
else:
|
||||
cleaned_user_api_key_metadata[k] = v
|
||||
|
||||
|
||||
@@ -2442,7 +2442,9 @@ def anthropic_messages_pt( # noqa: PLR0915
|
||||
_document_content_element = cast(
|
||||
AnthropicMessagesDocumentParam,
|
||||
add_cache_control_to_content(
|
||||
anthropic_content_element=cast(AnthropicMessagesDocumentParam, m),
|
||||
anthropic_content_element=cast(
|
||||
AnthropicMessagesDocumentParam, m
|
||||
),
|
||||
original_content_element=dict(m),
|
||||
),
|
||||
)
|
||||
@@ -2454,10 +2456,18 @@ def anthropic_messages_pt( # noqa: PLR0915
|
||||
)
|
||||
)
|
||||
_file_content_element = add_cache_control_to_content(
|
||||
anthropic_content_element=cast(AnthropicMessagesDocumentParam, _file_content_element),
|
||||
anthropic_content_element=cast(
|
||||
AnthropicMessagesDocumentParam,
|
||||
_file_content_element,
|
||||
),
|
||||
original_content_element=dict(m),
|
||||
)
|
||||
user_content.append(cast(AnthropicMessagesDocumentParam,_file_content_element))
|
||||
user_content.append(
|
||||
cast(
|
||||
AnthropicMessagesDocumentParam,
|
||||
_file_content_element,
|
||||
)
|
||||
)
|
||||
elif isinstance(user_message_types_block["content"], str):
|
||||
_anthropic_content_text_element: AnthropicMessagesTextParam = {
|
||||
"type": "text",
|
||||
|
||||
@@ -780,7 +780,7 @@ class LiteLLMAnthropicMessagesAdapter:
|
||||
# Keep Anthropic-native tools in their original format
|
||||
new_tools.append(tool) # type: ignore[arg-type]
|
||||
continue
|
||||
|
||||
|
||||
original_name = tool["name"]
|
||||
truncated_name = truncate_tool_name(original_name)
|
||||
|
||||
|
||||
@@ -336,9 +336,7 @@ class BaseVideoConfig(ABC):
|
||||
Returns:
|
||||
Tuple[str, Dict]: (url, data) for the POST request
|
||||
"""
|
||||
raise NotImplementedError(
|
||||
"video edit is not supported for this provider"
|
||||
)
|
||||
raise NotImplementedError("video edit is not supported for this provider")
|
||||
|
||||
def transform_video_edit_response(
|
||||
self,
|
||||
@@ -346,9 +344,7 @@ class BaseVideoConfig(ABC):
|
||||
logging_obj: LiteLLMLoggingObj,
|
||||
custom_llm_provider: Optional[str] = None,
|
||||
) -> VideoObject:
|
||||
raise NotImplementedError(
|
||||
"video edit is not supported for this provider"
|
||||
)
|
||||
raise NotImplementedError("video edit is not supported for this provider")
|
||||
|
||||
def transform_video_extension_request(
|
||||
self,
|
||||
@@ -366,9 +362,7 @@ class BaseVideoConfig(ABC):
|
||||
Returns:
|
||||
Tuple[str, Dict]: (url, data) for the POST request
|
||||
"""
|
||||
raise NotImplementedError(
|
||||
"video extension is not supported for this provider"
|
||||
)
|
||||
raise NotImplementedError("video extension is not supported for this provider")
|
||||
|
||||
def transform_video_extension_response(
|
||||
self,
|
||||
@@ -376,9 +370,7 @@ class BaseVideoConfig(ABC):
|
||||
logging_obj: LiteLLMLoggingObj,
|
||||
custom_llm_provider: Optional[str] = None,
|
||||
) -> VideoObject:
|
||||
raise NotImplementedError(
|
||||
"video extension is not supported for this provider"
|
||||
)
|
||||
raise NotImplementedError("video extension is not supported for this provider")
|
||||
|
||||
def get_error_class(
|
||||
self, error_message: str, status_code: int, headers: Union[dict, httpx.Headers]
|
||||
|
||||
@@ -6162,7 +6162,10 @@ class BaseLLMHTTPHandler:
|
||||
litellm_params=dict(litellm_params),
|
||||
)
|
||||
|
||||
url, files_list = video_provider_config.transform_video_create_character_request(
|
||||
(
|
||||
url,
|
||||
files_list,
|
||||
) = video_provider_config.transform_video_create_character_request(
|
||||
name=name,
|
||||
video=video,
|
||||
api_base=api_base,
|
||||
@@ -6230,7 +6233,10 @@ class BaseLLMHTTPHandler:
|
||||
litellm_params=dict(litellm_params),
|
||||
)
|
||||
|
||||
url, files_list = video_provider_config.transform_video_create_character_request(
|
||||
(
|
||||
url,
|
||||
files_list,
|
||||
) = video_provider_config.transform_video_create_character_request(
|
||||
name=name,
|
||||
video=video,
|
||||
api_base=api_base,
|
||||
@@ -6324,11 +6330,7 @@ class BaseLLMHTTPHandler:
|
||||
)
|
||||
|
||||
try:
|
||||
response = sync_httpx_client.get(
|
||||
url=url,
|
||||
headers=headers,
|
||||
params=params
|
||||
)
|
||||
response = sync_httpx_client.get(url=url, headers=headers, params=params)
|
||||
response.raise_for_status()
|
||||
return video_provider_config.transform_video_get_character_response(
|
||||
raw_response=response,
|
||||
@@ -6386,9 +6388,7 @@ class BaseLLMHTTPHandler:
|
||||
|
||||
try:
|
||||
response = await async_httpx_client.get(
|
||||
url=url,
|
||||
headers=headers,
|
||||
params=params
|
||||
url=url, headers=headers, params=params
|
||||
)
|
||||
response.raise_for_status()
|
||||
return video_provider_config.transform_video_get_character_response(
|
||||
|
||||
@@ -525,28 +525,47 @@ class GeminiVideoConfig(BaseVideoConfig):
|
||||
"""Video delete is not supported."""
|
||||
raise NotImplementedError("Video delete is not supported by Google Veo.")
|
||||
|
||||
def transform_video_create_character_request(self, name, video, api_base, litellm_params, headers):
|
||||
def transform_video_create_character_request(
|
||||
self, name, video, api_base, litellm_params, headers
|
||||
):
|
||||
raise NotImplementedError("video create character is not supported for Gemini")
|
||||
|
||||
def transform_video_create_character_response(self, raw_response, logging_obj):
|
||||
raise NotImplementedError("video create character is not supported for Gemini")
|
||||
|
||||
def transform_video_get_character_request(self, character_id, api_base, litellm_params, headers):
|
||||
def transform_video_get_character_request(
|
||||
self, character_id, api_base, litellm_params, headers
|
||||
):
|
||||
raise NotImplementedError("video get character is not supported for Gemini")
|
||||
|
||||
def transform_video_get_character_response(self, raw_response, logging_obj):
|
||||
raise NotImplementedError("video get character is not supported for Gemini")
|
||||
|
||||
def transform_video_edit_request(self, prompt, video_id, api_base, litellm_params, headers, extra_body=None):
|
||||
def transform_video_edit_request(
|
||||
self, prompt, video_id, api_base, litellm_params, headers, extra_body=None
|
||||
):
|
||||
raise NotImplementedError("video edit is not supported for Gemini")
|
||||
|
||||
def transform_video_edit_response(self, raw_response, logging_obj, custom_llm_provider=None):
|
||||
def transform_video_edit_response(
|
||||
self, raw_response, logging_obj, custom_llm_provider=None
|
||||
):
|
||||
raise NotImplementedError("video edit is not supported for Gemini")
|
||||
|
||||
def transform_video_extension_request(self, prompt, video_id, seconds, api_base, litellm_params, headers, extra_body=None):
|
||||
def transform_video_extension_request(
|
||||
self,
|
||||
prompt,
|
||||
video_id,
|
||||
seconds,
|
||||
api_base,
|
||||
litellm_params,
|
||||
headers,
|
||||
extra_body=None,
|
||||
):
|
||||
raise NotImplementedError("video extension is not supported for Gemini")
|
||||
|
||||
def transform_video_extension_response(self, raw_response, logging_obj, custom_llm_provider=None):
|
||||
def transform_video_extension_response(
|
||||
self, raw_response, logging_obj, custom_llm_provider=None
|
||||
):
|
||||
raise NotImplementedError("video extension is not supported for Gemini")
|
||||
|
||||
def get_error_class(
|
||||
|
||||
@@ -19,7 +19,8 @@ class MoonshotChatConfig(OpenAIGPTConfig):
|
||||
@overload
|
||||
def _transform_messages(
|
||||
self, messages: List[AllMessageValues], model: str, is_async: Literal[True]
|
||||
) -> Coroutine[Any, Any, List[AllMessageValues]]: ...
|
||||
) -> Coroutine[Any, Any, List[AllMessageValues]]:
|
||||
...
|
||||
|
||||
@overload
|
||||
def _transform_messages(
|
||||
@@ -27,7 +28,8 @@ class MoonshotChatConfig(OpenAIGPTConfig):
|
||||
messages: List[AllMessageValues],
|
||||
model: str,
|
||||
is_async: Literal[False] = False,
|
||||
) -> List[AllMessageValues]: ...
|
||||
) -> List[AllMessageValues]:
|
||||
...
|
||||
|
||||
def _transform_messages(
|
||||
self, messages: List[AllMessageValues], model: str, is_async: bool = False
|
||||
@@ -53,9 +55,13 @@ class MoonshotChatConfig(OpenAIGPTConfig):
|
||||
messages = handle_messages_with_content_list_to_str_conversion(messages)
|
||||
|
||||
if is_async:
|
||||
return super()._transform_messages(messages=messages, model=model, is_async=True)
|
||||
return super()._transform_messages(
|
||||
messages=messages, model=model, is_async=True
|
||||
)
|
||||
else:
|
||||
return super()._transform_messages(messages=messages, model=model, is_async=False)
|
||||
return super()._transform_messages(
|
||||
messages=messages, model=model, is_async=False
|
||||
)
|
||||
|
||||
def _get_openai_compatible_provider_info(
|
||||
self, api_base: Optional[str], api_key: Optional[str]
|
||||
@@ -141,7 +147,9 @@ class MoonshotChatConfig(OpenAIGPTConfig):
|
||||
optional_params["temperature"] = 0.3
|
||||
return optional_params
|
||||
|
||||
def fill_reasoning_content(self, messages: List[AllMessageValues]) -> List[AllMessageValues]:
|
||||
def fill_reasoning_content(
|
||||
self, messages: List[AllMessageValues]
|
||||
) -> List[AllMessageValues]:
|
||||
"""
|
||||
Moonshot reasoning models require `reasoning_content` on every assistant
|
||||
message that contains tool_calls (multi-turn tool-calling flows).
|
||||
|
||||
@@ -592,28 +592,51 @@ class RunwayMLVideoConfig(BaseVideoConfig):
|
||||
|
||||
return video_obj
|
||||
|
||||
def transform_video_create_character_request(self, name, video, api_base, litellm_params, headers):
|
||||
raise NotImplementedError("video create character is not supported for RunwayML")
|
||||
def transform_video_create_character_request(
|
||||
self, name, video, api_base, litellm_params, headers
|
||||
):
|
||||
raise NotImplementedError(
|
||||
"video create character is not supported for RunwayML"
|
||||
)
|
||||
|
||||
def transform_video_create_character_response(self, raw_response, logging_obj):
|
||||
raise NotImplementedError("video create character is not supported for RunwayML")
|
||||
raise NotImplementedError(
|
||||
"video create character is not supported for RunwayML"
|
||||
)
|
||||
|
||||
def transform_video_get_character_request(self, character_id, api_base, litellm_params, headers):
|
||||
def transform_video_get_character_request(
|
||||
self, character_id, api_base, litellm_params, headers
|
||||
):
|
||||
raise NotImplementedError("video get character is not supported for RunwayML")
|
||||
|
||||
def transform_video_get_character_response(self, raw_response, logging_obj):
|
||||
raise NotImplementedError("video get character is not supported for RunwayML")
|
||||
|
||||
def transform_video_edit_request(self, prompt, video_id, api_base, litellm_params, headers, extra_body=None):
|
||||
def transform_video_edit_request(
|
||||
self, prompt, video_id, api_base, litellm_params, headers, extra_body=None
|
||||
):
|
||||
raise NotImplementedError("video edit is not supported for RunwayML")
|
||||
|
||||
def transform_video_edit_response(self, raw_response, logging_obj, custom_llm_provider=None):
|
||||
def transform_video_edit_response(
|
||||
self, raw_response, logging_obj, custom_llm_provider=None
|
||||
):
|
||||
raise NotImplementedError("video edit is not supported for RunwayML")
|
||||
|
||||
def transform_video_extension_request(self, prompt, video_id, seconds, api_base, litellm_params, headers, extra_body=None):
|
||||
def transform_video_extension_request(
|
||||
self,
|
||||
prompt,
|
||||
video_id,
|
||||
seconds,
|
||||
api_base,
|
||||
litellm_params,
|
||||
headers,
|
||||
extra_body=None,
|
||||
):
|
||||
raise NotImplementedError("video extension is not supported for RunwayML")
|
||||
|
||||
def transform_video_extension_response(self, raw_response, logging_obj, custom_llm_provider=None):
|
||||
def transform_video_extension_response(
|
||||
self, raw_response, logging_obj, custom_llm_provider=None
|
||||
):
|
||||
raise NotImplementedError("video extension is not supported for RunwayML")
|
||||
|
||||
def get_error_class(
|
||||
|
||||
@@ -184,9 +184,7 @@ class SagemakerChatConfig(OpenAIGPTConfig, BaseAWSLLM):
|
||||
llm_provider = LlmProviders(custom_llm_provider)
|
||||
except ValueError:
|
||||
llm_provider = LlmProviders.SAGEMAKER_CHAT
|
||||
client = get_async_httpx_client(
|
||||
llm_provider=llm_provider, params={}
|
||||
)
|
||||
client = get_async_httpx_client(llm_provider=llm_provider, params={})
|
||||
|
||||
try:
|
||||
response = await client.post(
|
||||
|
||||
@@ -142,8 +142,8 @@ class VertexAIBatchTransformation:
|
||||
Gets the output file id from the Vertex AI Batch response
|
||||
"""
|
||||
|
||||
output_file_id: str = (
|
||||
response.get("outputInfo", OutputInfo()).get("gcsOutputDirectory", "")
|
||||
output_file_id: str = response.get("outputInfo", OutputInfo()).get(
|
||||
"gcsOutputDirectory", ""
|
||||
)
|
||||
if output_file_id:
|
||||
output_file_id = output_file_id.rstrip("/") + "/predictions.jsonl"
|
||||
|
||||
@@ -624,28 +624,51 @@ class VertexAIVideoConfig(BaseVideoConfig, VertexBase):
|
||||
"""Video delete is not supported."""
|
||||
raise NotImplementedError("Video delete is not supported by Vertex AI Veo.")
|
||||
|
||||
def transform_video_create_character_request(self, name, video, api_base, litellm_params, headers):
|
||||
raise NotImplementedError("video create character is not supported for Vertex AI")
|
||||
def transform_video_create_character_request(
|
||||
self, name, video, api_base, litellm_params, headers
|
||||
):
|
||||
raise NotImplementedError(
|
||||
"video create character is not supported for Vertex AI"
|
||||
)
|
||||
|
||||
def transform_video_create_character_response(self, raw_response, logging_obj):
|
||||
raise NotImplementedError("video create character is not supported for Vertex AI")
|
||||
raise NotImplementedError(
|
||||
"video create character is not supported for Vertex AI"
|
||||
)
|
||||
|
||||
def transform_video_get_character_request(self, character_id, api_base, litellm_params, headers):
|
||||
def transform_video_get_character_request(
|
||||
self, character_id, api_base, litellm_params, headers
|
||||
):
|
||||
raise NotImplementedError("video get character is not supported for Vertex AI")
|
||||
|
||||
def transform_video_get_character_response(self, raw_response, logging_obj):
|
||||
raise NotImplementedError("video get character is not supported for Vertex AI")
|
||||
|
||||
def transform_video_edit_request(self, prompt, video_id, api_base, litellm_params, headers, extra_body=None):
|
||||
def transform_video_edit_request(
|
||||
self, prompt, video_id, api_base, litellm_params, headers, extra_body=None
|
||||
):
|
||||
raise NotImplementedError("video edit is not supported for Vertex AI")
|
||||
|
||||
def transform_video_edit_response(self, raw_response, logging_obj, custom_llm_provider=None):
|
||||
def transform_video_edit_response(
|
||||
self, raw_response, logging_obj, custom_llm_provider=None
|
||||
):
|
||||
raise NotImplementedError("video edit is not supported for Vertex AI")
|
||||
|
||||
def transform_video_extension_request(self, prompt, video_id, seconds, api_base, litellm_params, headers, extra_body=None):
|
||||
def transform_video_extension_request(
|
||||
self,
|
||||
prompt,
|
||||
video_id,
|
||||
seconds,
|
||||
api_base,
|
||||
litellm_params,
|
||||
headers,
|
||||
extra_body=None,
|
||||
):
|
||||
raise NotImplementedError("video extension is not supported for Vertex AI")
|
||||
|
||||
def transform_video_extension_response(self, raw_response, logging_obj, custom_llm_provider=None):
|
||||
def transform_video_extension_response(
|
||||
self, raw_response, logging_obj, custom_llm_provider=None
|
||||
):
|
||||
raise NotImplementedError("video extension is not supported for Vertex AI")
|
||||
|
||||
def get_error_class(
|
||||
|
||||
+1
-3
@@ -7533,9 +7533,7 @@ def stream_chunk_builder( # noqa: PLR0915
|
||||
# the final chunk.
|
||||
all_annotations: list = []
|
||||
for ac in annotation_chunks:
|
||||
all_annotations.extend(
|
||||
ac["choices"][0]["delta"]["annotations"]
|
||||
)
|
||||
all_annotations.extend(ac["choices"][0]["delta"]["annotations"])
|
||||
response["choices"][0]["message"]["annotations"] = all_annotations
|
||||
|
||||
audio_chunks = [
|
||||
|
||||
@@ -32354,6 +32354,53 @@
|
||||
"supports_vision": true,
|
||||
"supports_web_search": true
|
||||
},
|
||||
"xai/grok-4.20-multi-agent-beta-0309": {
|
||||
"cache_read_input_token_cost": 2e-07,
|
||||
"input_cost_per_token": 2e-06,
|
||||
"litellm_provider": "xai",
|
||||
"max_input_tokens": 2000000,
|
||||
"max_output_tokens": 2000000,
|
||||
"max_tokens": 2000000,
|
||||
"mode": "chat",
|
||||
"output_cost_per_token": 6e-06,
|
||||
"source": "https://docs.x.ai/docs/models",
|
||||
"supports_function_calling": true,
|
||||
"supports_reasoning": true,
|
||||
"supports_tool_choice": true,
|
||||
"supports_vision": true,
|
||||
"supports_web_search": true
|
||||
},
|
||||
"xai/grok-4.20-beta-0309-reasoning": {
|
||||
"cache_read_input_token_cost": 2e-07,
|
||||
"input_cost_per_token": 2e-06,
|
||||
"litellm_provider": "xai",
|
||||
"max_input_tokens": 2000000,
|
||||
"max_output_tokens": 2000000,
|
||||
"max_tokens": 2000000,
|
||||
"mode": "chat",
|
||||
"output_cost_per_token": 6e-06,
|
||||
"source": "https://docs.x.ai/docs/models",
|
||||
"supports_function_calling": true,
|
||||
"supports_reasoning": true,
|
||||
"supports_tool_choice": true,
|
||||
"supports_vision": true,
|
||||
"supports_web_search": true
|
||||
},
|
||||
"xai/grok-4.20-beta-0309-non-reasoning": {
|
||||
"cache_read_input_token_cost": 2e-07,
|
||||
"input_cost_per_token": 2e-06,
|
||||
"litellm_provider": "xai",
|
||||
"max_input_tokens": 2000000,
|
||||
"max_output_tokens": 2000000,
|
||||
"max_tokens": 2000000,
|
||||
"mode": "chat",
|
||||
"output_cost_per_token": 6e-06,
|
||||
"source": "https://docs.x.ai/docs/models",
|
||||
"supports_function_calling": true,
|
||||
"supports_tool_choice": true,
|
||||
"supports_vision": true,
|
||||
"supports_web_search": true
|
||||
},
|
||||
"xai/grok-beta": {
|
||||
"input_cost_per_token": 5e-06,
|
||||
"litellm_provider": "xai",
|
||||
|
||||
@@ -677,7 +677,60 @@ async def oauth_authorization_server_mcp(
|
||||
# Alias for standard OpenID discovery
|
||||
@router.get("/.well-known/openid-configuration")
|
||||
async def openid_configuration(request: Request):
|
||||
return await oauth_authorization_server_mcp(request)
|
||||
response = await oauth_authorization_server_mcp(request)
|
||||
|
||||
# If MCPJWTSigner is active, augment the discovery doc with JWKS fields so
|
||||
# MCP servers and gateways (e.g. AWS Bedrock AgentCore Gateway) can resolve
|
||||
# the signing keys and verify liteLLM-issued tokens.
|
||||
try:
|
||||
from litellm.proxy.guardrails.guardrail_hooks.mcp_jwt_signer.mcp_jwt_signer import (
|
||||
get_mcp_jwt_signer,
|
||||
)
|
||||
|
||||
signer = get_mcp_jwt_signer()
|
||||
if signer is not None:
|
||||
request_base_url = get_request_base_url(request)
|
||||
if isinstance(response, dict):
|
||||
response = {
|
||||
**response,
|
||||
"jwks_uri": f"{request_base_url}/.well-known/jwks.json",
|
||||
"id_token_signing_alg_values_supported": ["RS256"],
|
||||
}
|
||||
except ImportError:
|
||||
pass
|
||||
|
||||
return response
|
||||
|
||||
|
||||
@router.get("/.well-known/jwks.json")
|
||||
async def jwks_json(request: Request):
|
||||
"""
|
||||
JSON Web Key Set endpoint.
|
||||
|
||||
Returns the RSA public key used by MCPJWTSigner to sign outbound MCP tokens.
|
||||
MCP servers and gateways use this endpoint to verify liteLLM-issued JWTs.
|
||||
|
||||
Returns an empty key set if MCPJWTSigner is not configured.
|
||||
"""
|
||||
try:
|
||||
from litellm.proxy.guardrails.guardrail_hooks.mcp_jwt_signer.mcp_jwt_signer import (
|
||||
get_mcp_jwt_signer,
|
||||
)
|
||||
|
||||
signer = get_mcp_jwt_signer()
|
||||
if signer is not None:
|
||||
return JSONResponse(
|
||||
content=signer.get_jwks(),
|
||||
headers={"Cache-Control": f"public, max-age={signer.jwks_max_age}"},
|
||||
)
|
||||
except ImportError:
|
||||
pass
|
||||
|
||||
# No signer active — return empty key set; short cache so activation is picked up quickly.
|
||||
return JSONResponse(
|
||||
content={"keys": []},
|
||||
headers={"Cache-Control": "public, max-age=60"},
|
||||
)
|
||||
|
||||
|
||||
# Additional legacy pattern support
|
||||
|
||||
@@ -1908,7 +1908,15 @@ class MCPServerManager:
|
||||
user_api_key_auth: Optional[UserAPIKeyAuth],
|
||||
proxy_logging_obj: ProxyLogging,
|
||||
server: MCPServer,
|
||||
):
|
||||
raw_headers: Optional[Dict[str, str]] = None,
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
Run pre-call checks and guardrail hooks for an MCP tool call.
|
||||
|
||||
Returns a dict that may contain:
|
||||
- "arguments": hook-modified tool arguments (only if changed)
|
||||
- "extra_headers": headers injected by pre_mcp_call guardrail hooks
|
||||
"""
|
||||
## check if the tool is allowed or banned for the given server
|
||||
if not self.check_allowed_or_banned_tools(name, server):
|
||||
raise HTTPException(
|
||||
@@ -1932,6 +1940,14 @@ class MCPServerManager:
|
||||
server=server,
|
||||
)
|
||||
|
||||
# Extract incoming Bearer token from raw request headers so
|
||||
# guardrails like MCPJWTSigner can verify + re-sign it (FR-5).
|
||||
normalized_raw = {k.lower(): v for k, v in (raw_headers or {}).items()}
|
||||
incoming_bearer_token: Optional[str] = None
|
||||
auth_hdr = normalized_raw.get("authorization", "")
|
||||
if auth_hdr.lower().startswith("bearer "):
|
||||
incoming_bearer_token = auth_hdr[len("bearer ") :]
|
||||
|
||||
pre_hook_kwargs = {
|
||||
"name": name,
|
||||
"arguments": arguments,
|
||||
@@ -1957,6 +1973,7 @@ class MCPServerManager:
|
||||
if user_api_key_auth
|
||||
else None
|
||||
),
|
||||
"incoming_bearer_token": incoming_bearer_token,
|
||||
}
|
||||
|
||||
# Create MCP request object for processing
|
||||
@@ -1969,6 +1986,7 @@ class MCPServerManager:
|
||||
mcp_request_obj, pre_hook_kwargs
|
||||
)
|
||||
|
||||
hook_result: Dict[str, Any] = {}
|
||||
try:
|
||||
# Use standard pre_call_hook
|
||||
modified_data = await proxy_logging_obj.pre_call_hook(
|
||||
@@ -1984,7 +2002,9 @@ class MCPServerManager:
|
||||
)
|
||||
)
|
||||
if modified_kwargs.get("arguments") != arguments:
|
||||
arguments = modified_kwargs["arguments"]
|
||||
hook_result["arguments"] = modified_kwargs["arguments"]
|
||||
if modified_kwargs.get("extra_headers"):
|
||||
hook_result["extra_headers"] = modified_kwargs["extra_headers"]
|
||||
|
||||
except (
|
||||
BlockedPiiEntityError,
|
||||
@@ -1995,6 +2015,8 @@ class MCPServerManager:
|
||||
verbose_logger.error(f"Guardrail blocked MCP tool call pre call: {str(e)}")
|
||||
raise e
|
||||
|
||||
return hook_result
|
||||
|
||||
def _create_during_hook_task(
|
||||
self,
|
||||
name: str,
|
||||
@@ -2047,6 +2069,7 @@ class MCPServerManager:
|
||||
raw_headers: Optional[Dict[str, str]],
|
||||
proxy_logging_obj: Optional[ProxyLogging],
|
||||
host_progress_callback: Optional[Callable] = None,
|
||||
hook_extra_headers: Optional[Dict[str, str]] = None,
|
||||
) -> CallToolResult:
|
||||
"""
|
||||
Call a regular MCP tool using the MCP client.
|
||||
@@ -2061,6 +2084,9 @@ class MCPServerManager:
|
||||
oauth2_headers: Optional OAuth2 headers
|
||||
raw_headers: Optional raw headers from the request
|
||||
proxy_logging_obj: Optional ProxyLogging object for hook integration
|
||||
host_progress_callback: Optional callback for progress updates
|
||||
hook_extra_headers: Optional headers injected by pre_mcp_call guardrail
|
||||
hooks. Merged last (highest priority) into outbound request headers.
|
||||
|
||||
Returns:
|
||||
CallToolResult from the MCP server
|
||||
@@ -2116,6 +2142,31 @@ class MCPServerManager:
|
||||
extra_headers = {}
|
||||
extra_headers.update(mcp_server.static_headers)
|
||||
|
||||
if hook_extra_headers:
|
||||
if extra_headers is None:
|
||||
extra_headers = {}
|
||||
if "Authorization" in hook_extra_headers:
|
||||
if "Authorization" in extra_headers:
|
||||
verbose_logger.warning(
|
||||
"MCPServerManager: hook_extra_headers 'Authorization' will overwrite "
|
||||
"the existing Authorization header from static_headers. "
|
||||
"The hook JWT will take precedence."
|
||||
)
|
||||
elif server_auth_header is not None:
|
||||
# server_auth_header is passed separately to _create_mcp_client as
|
||||
# auth_value. Both will reach the upstream server — warn so admins
|
||||
# know two Authorization credentials are being sent.
|
||||
verbose_logger.warning(
|
||||
"MCPServerManager: hook_extra_headers injects 'Authorization' while "
|
||||
"server '%s' already has a configured authentication_token. "
|
||||
"Both credentials will be sent; the hook header is in extra_headers "
|
||||
"and the server token is in auth_value — the upstream server decides "
|
||||
"which one wins. Consider unsetting authentication_token if you want "
|
||||
"the hook JWT to be the sole credential.",
|
||||
mcp_server.server_name or mcp_server.name,
|
||||
)
|
||||
extra_headers.update(hook_extra_headers)
|
||||
|
||||
stdio_env = self._build_stdio_env(mcp_server, raw_headers)
|
||||
|
||||
client = await self._create_mcp_client(
|
||||
@@ -2201,15 +2252,19 @@ class MCPServerManager:
|
||||
# Allow validation and modification of tool calls before execution
|
||||
# Using standard pre_call_hook
|
||||
#########################################################
|
||||
hook_result: Dict[str, Any] = {}
|
||||
if proxy_logging_obj:
|
||||
await self.pre_call_tool_check(
|
||||
hook_result = await self.pre_call_tool_check(
|
||||
name=name,
|
||||
arguments=arguments,
|
||||
server_name=server_name,
|
||||
user_api_key_auth=user_api_key_auth,
|
||||
proxy_logging_obj=proxy_logging_obj,
|
||||
server=mcp_server,
|
||||
raw_headers=raw_headers,
|
||||
)
|
||||
if "arguments" in hook_result:
|
||||
arguments = hook_result["arguments"]
|
||||
|
||||
# Prepare tasks for during hooks
|
||||
tasks = []
|
||||
@@ -2227,8 +2282,16 @@ class MCPServerManager:
|
||||
# For OpenAPI servers, call the tool handler directly instead of via MCP client
|
||||
if mcp_server.spec_path:
|
||||
verbose_logger.debug(
|
||||
f"Calling OpenAPI tool {name} directly via HTTP handler"
|
||||
"Calling OpenAPI tool %s directly via HTTP handler", name
|
||||
)
|
||||
if hook_result.get("extra_headers"):
|
||||
verbose_logger.warning(
|
||||
"pre_mcp_call hook returned extra_headers for OpenAPI-backed "
|
||||
"MCP server '%s' — header injection is not supported for "
|
||||
"OpenAPI servers; headers will be ignored. Use SSE/HTTP "
|
||||
"transport to enable hook header injection.",
|
||||
server_name,
|
||||
)
|
||||
tasks.append(
|
||||
asyncio.create_task(
|
||||
self._call_openapi_tool_handler(mcp_server, name, arguments)
|
||||
@@ -2247,6 +2310,7 @@ class MCPServerManager:
|
||||
raw_headers=raw_headers,
|
||||
proxy_logging_obj=proxy_logging_obj,
|
||||
host_progress_callback=host_progress_callback,
|
||||
hook_extra_headers=hook_result.get("extra_headers"),
|
||||
)
|
||||
|
||||
# For OpenAPI tools, await outside the client context
|
||||
|
||||
@@ -903,12 +903,12 @@ if MCP_AVAILABLE:
|
||||
try:
|
||||
client_id, client_secret, scopes = _extract_credentials(request)
|
||||
|
||||
_oauth2_flow: Optional[Literal["client_credentials", "authorization_code"]] = (
|
||||
request.oauth2_flow or (
|
||||
"client_credentials"
|
||||
if client_id and client_secret and request.token_url
|
||||
else None
|
||||
)
|
||||
_oauth2_flow: Optional[
|
||||
Literal["client_credentials", "authorization_code"]
|
||||
] = request.oauth2_flow or (
|
||||
"client_credentials"
|
||||
if client_id and client_secret and request.token_url
|
||||
else None
|
||||
)
|
||||
# client_credentials requires token_url to fetch a token; without it the
|
||||
# incoming auth header would be dropped with nothing to replace it.
|
||||
|
||||
@@ -2471,6 +2471,9 @@ class UserAPIKeyAuth(
|
||||
Any
|
||||
] = None # Expanded created_by user when expand=user is used
|
||||
end_user_object_permission: Optional[LiteLLM_ObjectPermissionTable] = None
|
||||
# Decoded upstream IdP claims (groups, roles, etc.) propagated by JWT auth machinery
|
||||
# and forwarded into outbound tokens by guardrails such as MCPJWTSigner.
|
||||
jwt_claims: Optional[Dict] = None
|
||||
|
||||
model_config = ConfigDict(arbitrary_types_allowed=True)
|
||||
|
||||
|
||||
@@ -680,7 +680,7 @@ def get_customer_user_header_from_mapping(user_id_mapping) -> Optional[list]:
|
||||
|
||||
if customer_headers_mappings:
|
||||
return customer_headers_mappings
|
||||
|
||||
|
||||
return None
|
||||
|
||||
|
||||
@@ -754,15 +754,11 @@ def get_end_user_id_from_request_body(
|
||||
user_id_str = str(header_value)
|
||||
if user_id_str.strip():
|
||||
return user_id_str
|
||||
|
||||
|
||||
elif isinstance(custom_header_name_to_check, str):
|
||||
for header_name, header_value in request_headers.items():
|
||||
if header_name.lower() == custom_header_name_to_check.lower():
|
||||
user_id_str = (
|
||||
str(header_value)
|
||||
if header_value is not None
|
||||
else ""
|
||||
)
|
||||
user_id_str = str(header_value) if header_value is not None else ""
|
||||
if user_id_str.strip():
|
||||
return user_id_str
|
||||
|
||||
|
||||
@@ -685,6 +685,7 @@ async def _user_api_key_auth_builder( # noqa: PLR0915
|
||||
do_standard_jwt_auth = True
|
||||
if jwt_handler.litellm_jwtauth.virtual_key_claim_field is not None:
|
||||
# Decode JWT to get claims without running full auth_builder
|
||||
jwt_claims: Optional[dict]
|
||||
if jwt_handler.litellm_jwtauth.oidc_userinfo_enabled:
|
||||
jwt_claims = await jwt_handler.get_oidc_userinfo(token=api_key)
|
||||
else:
|
||||
@@ -700,6 +701,7 @@ async def _user_api_key_auth_builder( # noqa: PLR0915
|
||||
)
|
||||
if valid_token is not None:
|
||||
api_key = valid_token.token or ""
|
||||
valid_token.jwt_claims = jwt_claims
|
||||
do_standard_jwt_auth = False
|
||||
# Fall through to virtual key checks
|
||||
|
||||
@@ -729,6 +731,7 @@ async def _user_api_key_auth_builder( # noqa: PLR0915
|
||||
team_membership: Optional[LiteLLM_TeamMembership] = result.get(
|
||||
"team_membership", None
|
||||
)
|
||||
jwt_claims = result.get("jwt_claims", None)
|
||||
|
||||
global_proxy_spend = await get_global_proxy_spend(
|
||||
litellm_proxy_admin_name=litellm_proxy_admin_name,
|
||||
@@ -757,6 +760,7 @@ async def _user_api_key_auth_builder( # noqa: PLR0915
|
||||
org_id=org_id,
|
||||
end_user_id=end_user_id,
|
||||
parent_otel_span=parent_otel_span,
|
||||
jwt_claims=jwt_claims,
|
||||
)
|
||||
|
||||
valid_token = UserAPIKeyAuth(
|
||||
@@ -803,6 +807,7 @@ async def _user_api_key_auth_builder( # noqa: PLR0915
|
||||
team_metadata=(
|
||||
team_object.metadata if team_object is not None else None
|
||||
),
|
||||
jwt_claims=jwt_claims,
|
||||
)
|
||||
|
||||
# Check if model has zero cost - if so, skip all budget checks
|
||||
|
||||
@@ -537,9 +537,10 @@ async def retrieve_batch( # noqa: PLR0915
|
||||
)
|
||||
|
||||
# Fix: bug_feb14_batch_retrieve_returns_raw_input_file_id
|
||||
# Resolve raw provider input_file_id to unified ID.
|
||||
# Resolve raw provider file IDs (input, output, error) to unified IDs.
|
||||
if unified_batch_id:
|
||||
await resolve_input_file_id_to_unified(response, prisma_client)
|
||||
await resolve_output_file_ids_to_unified(response, prisma_client)
|
||||
|
||||
### ALERTING ###
|
||||
asyncio.create_task(
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
"""MCP JWT Signer guardrail — built-in LiteLLM guardrail for zero trust MCP auth."""
|
||||
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from litellm.types.guardrails import SupportedGuardrailIntegrations
|
||||
|
||||
from .mcp_jwt_signer import MCPJWTSigner, get_mcp_jwt_signer
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from litellm.types.guardrails import Guardrail, LitellmParams
|
||||
|
||||
|
||||
def initialize_guardrail(
|
||||
litellm_params: "LitellmParams", guardrail: "Guardrail"
|
||||
) -> MCPJWTSigner:
|
||||
import litellm
|
||||
|
||||
guardrail_name = guardrail.get("guardrail_name")
|
||||
if not guardrail_name:
|
||||
raise ValueError("MCPJWTSigner guardrail requires a guardrail_name")
|
||||
|
||||
mode = litellm_params.mode
|
||||
if mode != "pre_mcp_call":
|
||||
raise ValueError(
|
||||
f"MCPJWTSigner guardrail '{guardrail_name}' has mode='{mode}' but must use "
|
||||
"mode='pre_mcp_call'. JWT injection only fires for MCP tool calls."
|
||||
)
|
||||
|
||||
optional_params = getattr(litellm_params, "optional_params", None)
|
||||
|
||||
def _get(key): # type: ignore[no-untyped-def]
|
||||
if optional_params is not None:
|
||||
v = getattr(optional_params, key, None)
|
||||
if v is not None:
|
||||
return v
|
||||
return getattr(litellm_params, key, None)
|
||||
|
||||
signer = MCPJWTSigner(
|
||||
guardrail_name=guardrail_name,
|
||||
event_hook=litellm_params.mode,
|
||||
default_on=litellm_params.default_on,
|
||||
# Core signing
|
||||
issuer=_get("issuer"),
|
||||
audience=_get("audience"),
|
||||
ttl_seconds=_get("ttl_seconds"),
|
||||
# FR-5: verify + re-sign
|
||||
access_token_discovery_uri=_get("access_token_discovery_uri"),
|
||||
token_introspection_endpoint=_get("token_introspection_endpoint"),
|
||||
verify_issuer=_get("verify_issuer"),
|
||||
verify_audience=_get("verify_audience"),
|
||||
# FR-12: end-user identity mapping
|
||||
end_user_claim_sources=_get("end_user_claim_sources"),
|
||||
# FR-13: claim operations
|
||||
add_claims=_get("add_claims"),
|
||||
set_claims=_get("set_claims"),
|
||||
remove_claims=_get("remove_claims"),
|
||||
# FR-14: two-token model
|
||||
channel_token_audience=_get("channel_token_audience"),
|
||||
channel_token_ttl=_get("channel_token_ttl"),
|
||||
# FR-15: incoming claim validation
|
||||
required_claims=_get("required_claims"),
|
||||
optional_claims=_get("optional_claims"),
|
||||
# FR-9: debug headers
|
||||
debug_headers=_get("debug_headers") or False,
|
||||
# FR-10: configurable scopes
|
||||
allowed_scopes=_get("allowed_scopes"),
|
||||
)
|
||||
litellm.logging_callback_manager.add_litellm_callback(signer)
|
||||
return signer
|
||||
|
||||
|
||||
guardrail_initializer_registry = {
|
||||
SupportedGuardrailIntegrations.MCP_JWT_SIGNER.value: initialize_guardrail,
|
||||
}
|
||||
|
||||
guardrail_class_registry = {
|
||||
SupportedGuardrailIntegrations.MCP_JWT_SIGNER.value: MCPJWTSigner,
|
||||
}
|
||||
|
||||
__all__ = [
|
||||
"MCPJWTSigner",
|
||||
"initialize_guardrail",
|
||||
"get_mcp_jwt_signer",
|
||||
]
|
||||
@@ -0,0 +1,891 @@
|
||||
"""
|
||||
MCPJWTSigner — Built-in LiteLLM guardrail for zero trust MCP authentication.
|
||||
|
||||
Signs outbound MCP requests with a LiteLLM-issued RS256 JWT so that MCP servers
|
||||
can trust a single signing authority (liteLLM) instead of every upstream IdP.
|
||||
|
||||
Usage in config.yaml:
|
||||
|
||||
guardrails:
|
||||
- guardrail_name: "mcp-jwt-signer"
|
||||
litellm_params:
|
||||
guardrail: mcp_jwt_signer
|
||||
mode: "pre_mcp_call"
|
||||
default_on: true
|
||||
|
||||
# Core signing config
|
||||
issuer: "https://my-litellm.example.com" # optional
|
||||
audience: "mcp" # optional
|
||||
ttl_seconds: 300 # optional
|
||||
|
||||
# FR-5: Verify + re-sign — validate incoming Bearer token before signing
|
||||
access_token_discovery_uri: "https://idp.example.com/.well-known/openid-configuration"
|
||||
token_introspection_endpoint: "https://idp.example.com/introspect" # opaque tokens
|
||||
verify_issuer: "https://idp.example.com" # expected iss in incoming JWT
|
||||
verify_audience: "api://my-app" # expected aud in incoming JWT
|
||||
|
||||
# FR-12: End-user identity mapping — ordered resolution chain
|
||||
# Supported: token:<claim>, litellm:user_id, litellm:email,
|
||||
# litellm:end_user_id, litellm:team_id
|
||||
end_user_claim_sources:
|
||||
- "token:sub"
|
||||
- "token:email"
|
||||
- "litellm:user_id"
|
||||
|
||||
# FR-13: Claim operations
|
||||
add_claims: # add if key not already present in the JWT
|
||||
deployment_id: "prod-001"
|
||||
set_claims: # always set (overrides computed value)
|
||||
env: "production"
|
||||
remove_claims: # remove from final JWT
|
||||
- "nbf"
|
||||
|
||||
# FR-14: Two-token model — issue a second JWT for the MCP transport channel
|
||||
channel_token_audience: "bedrock-gateway"
|
||||
channel_token_ttl: 60
|
||||
|
||||
# FR-15: Incoming claim validation — enforce required IdP claims
|
||||
required_claims:
|
||||
- "sub"
|
||||
- "email"
|
||||
optional_claims: # pass through from jwt_claims into outbound JWT
|
||||
- "groups"
|
||||
- "roles"
|
||||
|
||||
# FR-9: Debug headers
|
||||
debug_headers: false # emit x-litellm-mcp-debug header when true
|
||||
|
||||
# FR-10: Configurable scopes — explicit list replaces auto-generation
|
||||
allowed_scopes:
|
||||
- "mcp:tools/call"
|
||||
- "mcp:tools/list"
|
||||
|
||||
MCP servers verify tokens via:
|
||||
GET /.well-known/openid-configuration → { jwks_uri: ".../.well-known/jwks.json" }
|
||||
GET /.well-known/jwks.json → RSA public key in JWKS format
|
||||
|
||||
Optionally set MCP_JWT_SIGNING_KEY env var (PEM string or file:///path) to use
|
||||
your own RSA keypair. If unset, an RSA-2048 keypair is auto-generated at startup.
|
||||
"""
|
||||
|
||||
import base64
|
||||
import hashlib
|
||||
import os
|
||||
import re
|
||||
import time
|
||||
from typing import Any, Dict, List, Optional, Union
|
||||
|
||||
import jwt
|
||||
from cryptography.hazmat.primitives import serialization
|
||||
from cryptography.hazmat.primitives.asymmetric import rsa
|
||||
from cryptography.hazmat.primitives.asymmetric.rsa import RSAPrivateKey
|
||||
|
||||
from litellm._logging import verbose_proxy_logger
|
||||
from litellm.caching import DualCache
|
||||
from litellm.integrations.custom_guardrail import (
|
||||
CustomGuardrail,
|
||||
log_guardrail_information,
|
||||
)
|
||||
from litellm.proxy._types import UserAPIKeyAuth
|
||||
from litellm.types.utils import CallTypesLiteral
|
||||
|
||||
# Module-level singleton for the JWKS discovery endpoint to access.
|
||||
_mcp_jwt_signer_instance: Optional["MCPJWTSigner"] = None
|
||||
|
||||
# Simple in-memory JWKS cache: keyed by JWKS URI → (keys_list, fetched_at).
|
||||
_jwks_cache: Dict[str, tuple] = {}
|
||||
_JWKS_CACHE_TTL = 3600 # 1 hour
|
||||
|
||||
|
||||
def get_mcp_jwt_signer() -> Optional["MCPJWTSigner"]:
|
||||
"""Return the active MCPJWTSigner singleton, or None if not initialized."""
|
||||
return _mcp_jwt_signer_instance
|
||||
|
||||
|
||||
def _load_private_key_from_env(env_var: str) -> RSAPrivateKey:
|
||||
"""Load an RSA private key from an env var (PEM string or file:// path)."""
|
||||
key_material = os.environ.get(env_var, "")
|
||||
if not key_material:
|
||||
raise ValueError(
|
||||
f"MCPJWTSigner: environment variable '{env_var}' is set but empty."
|
||||
)
|
||||
if key_material.startswith("file://"):
|
||||
path = key_material[len("file://") :]
|
||||
with open(path, "rb") as f:
|
||||
key_bytes = f.read()
|
||||
else:
|
||||
key_bytes = key_material.encode("utf-8")
|
||||
return serialization.load_pem_private_key(key_bytes, password=None) # type: ignore[return-value]
|
||||
|
||||
|
||||
def _generate_rsa_key_pair() -> RSAPrivateKey:
|
||||
"""Generate a new RSA-2048 private key."""
|
||||
return rsa.generate_private_key(
|
||||
public_exponent=65537,
|
||||
key_size=2048,
|
||||
)
|
||||
|
||||
|
||||
def _int_to_base64url(n: int) -> str:
|
||||
"""Encode an integer as a base64url string (no padding)."""
|
||||
byte_length = (n.bit_length() + 7) // 8
|
||||
return (
|
||||
base64.urlsafe_b64encode(n.to_bytes(byte_length, byteorder="big"))
|
||||
.rstrip(b"=")
|
||||
.decode("ascii")
|
||||
)
|
||||
|
||||
|
||||
def _compute_kid(public_key: Any) -> str:
|
||||
"""Derive a key ID from the public key's DER encoding (SHA-256, first 16 hex chars)."""
|
||||
der_bytes = public_key.public_bytes(
|
||||
encoding=serialization.Encoding.DER,
|
||||
format=serialization.PublicFormat.SubjectPublicKeyInfo,
|
||||
)
|
||||
return hashlib.sha256(der_bytes).hexdigest()[:16]
|
||||
|
||||
|
||||
async def _fetch_jwks(jwks_uri: str) -> List[Dict[str, Any]]:
|
||||
"""
|
||||
Fetch and cache a JWKS from the given URI.
|
||||
|
||||
Results are cached for _JWKS_CACHE_TTL seconds to avoid hammering the IdP.
|
||||
"""
|
||||
now = time.time()
|
||||
cached = _jwks_cache.get(jwks_uri)
|
||||
if cached is not None:
|
||||
keys, fetched_at = cached
|
||||
if now - fetched_at < _JWKS_CACHE_TTL:
|
||||
return keys # type: ignore[return-value]
|
||||
|
||||
from litellm.llms.custom_httpx.http_handler import (
|
||||
get_async_httpx_client,
|
||||
httpxSpecialProvider,
|
||||
)
|
||||
|
||||
client = get_async_httpx_client(llm_provider=httpxSpecialProvider.Oauth2Check)
|
||||
resp = await client.get(jwks_uri, headers={"Accept": "application/json"})
|
||||
resp.raise_for_status()
|
||||
keys = resp.json().get("keys", [])
|
||||
_jwks_cache[jwks_uri] = (keys, now)
|
||||
return keys # type: ignore[return-value]
|
||||
|
||||
|
||||
async def _fetch_oidc_discovery(discovery_uri: str) -> Dict[str, Any]:
|
||||
"""Fetch an OIDC discovery document and return its parsed JSON."""
|
||||
from litellm.llms.custom_httpx.http_handler import (
|
||||
get_async_httpx_client,
|
||||
httpxSpecialProvider,
|
||||
)
|
||||
|
||||
client = get_async_httpx_client(llm_provider=httpxSpecialProvider.Oauth2Check)
|
||||
resp = await client.get(discovery_uri, headers={"Accept": "application/json"})
|
||||
resp.raise_for_status()
|
||||
return resp.json() # type: ignore[return-value]
|
||||
|
||||
|
||||
class MCPJWTSigner(CustomGuardrail):
|
||||
"""
|
||||
Built-in LiteLLM guardrail that signs outbound MCP requests with a
|
||||
LiteLLM-issued RS256 JWT, enabling zero trust authentication.
|
||||
|
||||
MCP servers verify tokens using liteLLM's OIDC discovery endpoint and
|
||||
JWKS endpoint rather than trusting each upstream IdP directly.
|
||||
|
||||
The signed JWT carries:
|
||||
- iss: LiteLLM issuer identifier
|
||||
- aud: MCP audience (configurable)
|
||||
- sub: End-user identity (resolved via end_user_claim_sources, RFC 8693)
|
||||
- act: Actor/agent identity (team_id or org_id, RFC 8693 delegation)
|
||||
- scope: Tool-level access scopes (configurable via allowed_scopes)
|
||||
- iat, exp, nbf: Standard timing claims
|
||||
|
||||
Feature set:
|
||||
FR-5: Verify + re-sign (access_token_discovery_uri, token_introspection_endpoint)
|
||||
FR-9: Debug headers (debug_headers)
|
||||
FR-10: Configurable scopes (allowed_scopes)
|
||||
FR-12: Configurable end-user identity mapping (end_user_claim_sources)
|
||||
FR-13: Claim operations (add_claims, set_claims, remove_claims)
|
||||
FR-14: Two-token model (channel_token_audience, channel_token_ttl)
|
||||
FR-15: Incoming claim validation (required_claims, optional_claims)
|
||||
"""
|
||||
|
||||
ALGORITHM = "RS256"
|
||||
DEFAULT_TTL = 300
|
||||
DEFAULT_AUDIENCE = "mcp"
|
||||
SIGNING_KEY_ENV = "MCP_JWT_SIGNING_KEY"
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
# Core signing config
|
||||
issuer: Optional[str] = None,
|
||||
audience: Optional[str] = None,
|
||||
ttl_seconds: Optional[int] = None,
|
||||
# FR-5: Verify + re-sign
|
||||
access_token_discovery_uri: Optional[str] = None,
|
||||
token_introspection_endpoint: Optional[str] = None,
|
||||
verify_issuer: Optional[str] = None,
|
||||
verify_audience: Optional[str] = None,
|
||||
# FR-12: End-user identity mapping
|
||||
end_user_claim_sources: Optional[List[str]] = None,
|
||||
# FR-13: Claim operations
|
||||
add_claims: Optional[Dict[str, Any]] = None,
|
||||
set_claims: Optional[Dict[str, Any]] = None,
|
||||
remove_claims: Optional[List[str]] = None,
|
||||
# FR-14: Two-token model
|
||||
channel_token_audience: Optional[str] = None,
|
||||
channel_token_ttl: Optional[int] = None,
|
||||
# FR-15: Incoming claim validation
|
||||
required_claims: Optional[List[str]] = None,
|
||||
optional_claims: Optional[List[str]] = None,
|
||||
# FR-9: Debug headers
|
||||
debug_headers: bool = False,
|
||||
# FR-10: Configurable scopes
|
||||
allowed_scopes: Optional[List[str]] = None,
|
||||
**kwargs: Any,
|
||||
) -> None:
|
||||
super().__init__(**kwargs)
|
||||
|
||||
# --- Signing key setup ---
|
||||
key_material = os.environ.get(self.SIGNING_KEY_ENV)
|
||||
if key_material:
|
||||
self._private_key = _load_private_key_from_env(self.SIGNING_KEY_ENV)
|
||||
self._persistent_key: bool = True
|
||||
verbose_proxy_logger.info(
|
||||
"MCPJWTSigner: loaded RSA key from env var %s", self.SIGNING_KEY_ENV
|
||||
)
|
||||
else:
|
||||
self._private_key = _generate_rsa_key_pair()
|
||||
self._persistent_key = False
|
||||
verbose_proxy_logger.info(
|
||||
"MCPJWTSigner: auto-generated RSA-2048 keypair (set %s to use your own key)",
|
||||
self.SIGNING_KEY_ENV,
|
||||
)
|
||||
|
||||
self._public_key = self._private_key.public_key()
|
||||
self._kid = _compute_kid(self._public_key)
|
||||
|
||||
# --- Core config ---
|
||||
self.issuer: str = (
|
||||
issuer
|
||||
or os.environ.get("MCP_JWT_ISSUER")
|
||||
or os.environ.get("LITELLM_EXTERNAL_URL")
|
||||
or "litellm"
|
||||
)
|
||||
self.audience: str = (
|
||||
audience or os.environ.get("MCP_JWT_AUDIENCE") or self.DEFAULT_AUDIENCE
|
||||
)
|
||||
resolved_ttl = int(
|
||||
ttl_seconds
|
||||
if ttl_seconds is not None
|
||||
else os.environ.get("MCP_JWT_TTL_SECONDS", str(self.DEFAULT_TTL))
|
||||
)
|
||||
if resolved_ttl <= 0:
|
||||
raise ValueError(
|
||||
f"MCPJWTSigner: ttl_seconds must be > 0, got {resolved_ttl}"
|
||||
)
|
||||
self.ttl_seconds: int = resolved_ttl
|
||||
|
||||
# --- FR-5: Verify + re-sign ---
|
||||
self.access_token_discovery_uri: Optional[str] = access_token_discovery_uri
|
||||
self.token_introspection_endpoint: Optional[str] = token_introspection_endpoint
|
||||
self.verify_issuer: Optional[str] = verify_issuer
|
||||
self.verify_audience: Optional[str] = verify_audience
|
||||
# Cached OIDC discovery document (fetched lazily, TTL = 24 h)
|
||||
self._oidc_discovery_doc: Optional[Dict[str, Any]] = None
|
||||
self._oidc_discovery_fetched_at: float = 0.0
|
||||
|
||||
# --- FR-12: End-user identity mapping ---
|
||||
# Default chain: try incoming JWT sub, fall back to litellm user_id
|
||||
self.end_user_claim_sources: List[str] = end_user_claim_sources or [
|
||||
"token:sub",
|
||||
"litellm:user_id",
|
||||
]
|
||||
|
||||
# --- FR-13: Claim operations ---
|
||||
self.add_claims: Dict[str, Any] = add_claims or {}
|
||||
self.set_claims: Dict[str, Any] = set_claims or {}
|
||||
self.remove_claims: List[str] = remove_claims or []
|
||||
|
||||
# --- FR-14: Two-token model ---
|
||||
self.channel_token_audience: Optional[str] = channel_token_audience
|
||||
self.channel_token_ttl: int = (
|
||||
channel_token_ttl if channel_token_ttl is not None else self.ttl_seconds
|
||||
)
|
||||
|
||||
# --- FR-15: Incoming claim validation ---
|
||||
self.required_claims: List[str] = required_claims or []
|
||||
self.optional_claims: List[str] = optional_claims or []
|
||||
|
||||
# --- FR-9: Debug headers ---
|
||||
self.debug_headers: bool = debug_headers
|
||||
|
||||
# --- FR-10: Configurable scopes ---
|
||||
self.allowed_scopes: Optional[List[str]] = allowed_scopes
|
||||
|
||||
# Register singleton for JWKS/OIDC discovery endpoints.
|
||||
global _mcp_jwt_signer_instance
|
||||
if _mcp_jwt_signer_instance is not None:
|
||||
verbose_proxy_logger.warning(
|
||||
"MCPJWTSigner: replacing existing singleton — previously issued tokens "
|
||||
"signed with the old key will fail JWKS verification. "
|
||||
"Avoid configuring multiple mcp_jwt_signer guardrails."
|
||||
)
|
||||
_mcp_jwt_signer_instance = self
|
||||
|
||||
verbose_proxy_logger.info(
|
||||
"MCPJWTSigner initialized: issuer=%s audience=%s ttl=%ds kid=%s "
|
||||
"verify=%s channel_token=%s debug=%s",
|
||||
self.issuer,
|
||||
self.audience,
|
||||
self.ttl_seconds,
|
||||
self._kid,
|
||||
bool(self.access_token_discovery_uri),
|
||||
bool(self.channel_token_audience),
|
||||
self.debug_headers,
|
||||
)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Public helpers (used by /.well-known/jwks.json endpoint)
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
@property
|
||||
def jwks_max_age(self) -> int:
|
||||
"""
|
||||
Recommended Cache-Control max-age for the JWKS response (seconds).
|
||||
|
||||
1 hour for persistent keys; 5 minutes for auto-generated keys so MCP
|
||||
servers re-fetch quickly after a proxy restart.
|
||||
"""
|
||||
return 3600 if self._persistent_key else 300
|
||||
|
||||
def get_jwks(self) -> Dict[str, Any]:
|
||||
"""
|
||||
Return the JWKS for the RSA public key.
|
||||
Used by GET /.well-known/jwks.json so MCP servers can verify tokens.
|
||||
"""
|
||||
public_numbers = self._public_key.public_numbers()
|
||||
return {
|
||||
"keys": [
|
||||
{
|
||||
"kty": "RSA",
|
||||
"alg": self.ALGORITHM,
|
||||
"use": "sig",
|
||||
"kid": self._kid,
|
||||
"n": _int_to_base64url(public_numbers.n),
|
||||
"e": _int_to_base64url(public_numbers.e),
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# FR-5: Verify + re-sign helpers
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
# 24-hour TTL for the OIDC discovery doc — long enough to avoid hammering
|
||||
# the IdP, short enough to pick up jwks_uri changes after key rotation.
|
||||
_OIDC_DISCOVERY_TTL = 86400
|
||||
|
||||
async def _get_oidc_discovery(self) -> Dict[str, Any]:
|
||||
"""Fetch and cache the OIDC discovery document with a 24-hour TTL.
|
||||
|
||||
Only caches when the doc contains a 'jwks_uri' so that a transient or
|
||||
malformed response doesn't permanently disable JWT verification.
|
||||
"""
|
||||
now = time.time()
|
||||
cache_expired = (
|
||||
now - self._oidc_discovery_fetched_at
|
||||
) >= self._OIDC_DISCOVERY_TTL
|
||||
if (
|
||||
self._oidc_discovery_doc is None or cache_expired
|
||||
) and self.access_token_discovery_uri:
|
||||
doc = await _fetch_oidc_discovery(self.access_token_discovery_uri)
|
||||
if "jwks_uri" in doc:
|
||||
self._oidc_discovery_doc = doc
|
||||
self._oidc_discovery_fetched_at = now
|
||||
else:
|
||||
return doc
|
||||
return self._oidc_discovery_doc or {}
|
||||
|
||||
async def _verify_incoming_jwt(self, raw_token: str) -> Dict[str, Any]:
|
||||
"""
|
||||
Verify an incoming Bearer JWT against the configured IdP's JWKS.
|
||||
|
||||
Returns the verified payload claims dict.
|
||||
Raises jwt.PyJWTError (or subclass) if verification fails.
|
||||
"""
|
||||
discovery = await self._get_oidc_discovery()
|
||||
jwks_uri = discovery.get("jwks_uri")
|
||||
if not jwks_uri:
|
||||
raise ValueError(
|
||||
"MCPJWTSigner: access_token_discovery_uri discovery document "
|
||||
f"at {self.access_token_discovery_uri!r} has no 'jwks_uri'."
|
||||
)
|
||||
|
||||
jwks_keys = await _fetch_jwks(jwks_uri)
|
||||
|
||||
# Only read `kid` from the unverified header — never `alg`.
|
||||
# Reading `alg` from an attacker-controlled header enables algorithm
|
||||
# confusion attacks (e.g. alg:none, HS256 with the public key as secret).
|
||||
# The algorithm is determined from the JWKS key entry instead.
|
||||
unverified_header = jwt.get_unverified_header(raw_token)
|
||||
kid = unverified_header.get("kid")
|
||||
|
||||
# Build a JWKS object and pick the matching key.
|
||||
# PyJWT's PyJWKSet handles key-type parsing and kid matching correctly.
|
||||
from jwt import PyJWKSet
|
||||
|
||||
try:
|
||||
jwks_set = PyJWKSet.from_dict({"keys": jwks_keys})
|
||||
except Exception as exc:
|
||||
raise jwt.exceptions.PyJWKSetError( # type: ignore[attr-defined]
|
||||
f"Failed to parse JWKS from {jwks_uri!r}: {exc}"
|
||||
) from exc
|
||||
|
||||
signing_jwk = None
|
||||
for jwk_obj in jwks_set.keys:
|
||||
if not kid or jwk_obj.key_id == kid:
|
||||
signing_jwk = jwk_obj
|
||||
break
|
||||
|
||||
if signing_jwk is None:
|
||||
raise jwt.exceptions.PyJWKSetError( # type: ignore[attr-defined]
|
||||
f"No JWKS key matching kid={kid!r} at {jwks_uri!r}"
|
||||
)
|
||||
|
||||
# Use the algorithm declared by the JWKS key entry, not the token header.
|
||||
# PyJWT populates algorithm_name from the key's `alg` field; when absent
|
||||
# it infers from the key type (RSAPublicKey → RS256).
|
||||
alg = getattr(signing_jwk, "algorithm_name", None) or "RS256"
|
||||
|
||||
decode_options: Dict[str, Any] = {"verify_exp": True}
|
||||
decode_kwargs: Dict[str, Any] = {
|
||||
"algorithms": [alg],
|
||||
"options": decode_options,
|
||||
}
|
||||
if self.verify_audience:
|
||||
decode_kwargs["audience"] = self.verify_audience
|
||||
else:
|
||||
decode_options["verify_aud"] = False
|
||||
|
||||
if self.verify_issuer:
|
||||
decode_kwargs["issuer"] = self.verify_issuer
|
||||
|
||||
payload: Dict[str, Any] = jwt.decode(
|
||||
raw_token, signing_jwk.key, **decode_kwargs
|
||||
)
|
||||
return payload
|
||||
|
||||
async def _introspect_opaque_token(self, token: str) -> Dict[str, Any]:
|
||||
"""
|
||||
Perform RFC 7662 token introspection for opaque (non-JWT) tokens.
|
||||
|
||||
Returns the introspection response dict. Raises on HTTP error or
|
||||
inactive token.
|
||||
"""
|
||||
if not self.token_introspection_endpoint:
|
||||
raise ValueError(
|
||||
"MCPJWTSigner: token_introspection_endpoint is required for "
|
||||
"opaque token verification but is not configured."
|
||||
)
|
||||
|
||||
from litellm.llms.custom_httpx.http_handler import (
|
||||
get_async_httpx_client,
|
||||
httpxSpecialProvider,
|
||||
)
|
||||
|
||||
client = get_async_httpx_client(llm_provider=httpxSpecialProvider.Oauth2Check)
|
||||
resp = await client.post(
|
||||
self.token_introspection_endpoint,
|
||||
data={"token": token},
|
||||
headers={"Accept": "application/json"},
|
||||
)
|
||||
resp.raise_for_status()
|
||||
result: Dict[str, Any] = resp.json()
|
||||
if not result.get("active", False):
|
||||
raise jwt.exceptions.ExpiredSignatureError( # type: ignore[attr-defined]
|
||||
"MCPJWTSigner: incoming token is inactive (introspection returned active=false)"
|
||||
)
|
||||
return result
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# FR-15: Incoming claim validation
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def _validate_required_claims(
|
||||
self,
|
||||
jwt_claims: Optional[Dict[str, Any]],
|
||||
) -> None:
|
||||
"""
|
||||
Raise HTTP 403 if any required_claims are absent from the verified
|
||||
incoming token claims.
|
||||
"""
|
||||
if not self.required_claims:
|
||||
return
|
||||
|
||||
from fastapi import HTTPException
|
||||
|
||||
missing = [c for c in self.required_claims if not (jwt_claims or {}).get(c)]
|
||||
if missing:
|
||||
raise HTTPException(
|
||||
status_code=403,
|
||||
detail={
|
||||
"error": (
|
||||
f"MCPJWTSigner: incoming token is missing required claims: "
|
||||
f"{missing}. Configure the IdP to include these claims."
|
||||
)
|
||||
},
|
||||
)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# FR-12: End-user identity mapping
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def _resolve_end_user_identity(
|
||||
self,
|
||||
user_api_key_dict: UserAPIKeyAuth,
|
||||
jwt_claims: Optional[Dict[str, Any]],
|
||||
) -> str:
|
||||
"""
|
||||
Resolve the outbound JWT 'sub' using the ordered end_user_claim_sources list.
|
||||
|
||||
Supported source prefixes:
|
||||
token:<claim> — from verified incoming JWT / introspection claims
|
||||
litellm:user_id — from UserAPIKeyAuth.user_id
|
||||
litellm:email — from UserAPIKeyAuth.user_email
|
||||
litellm:end_user_id — from UserAPIKeyAuth.end_user_id
|
||||
litellm:team_id — from UserAPIKeyAuth.team_id
|
||||
|
||||
Falls back to a stable hash of the API token for service-account callers.
|
||||
"""
|
||||
for source in self.end_user_claim_sources:
|
||||
value: Optional[str] = None
|
||||
|
||||
if source.startswith("token:"):
|
||||
claim_name = source[len("token:") :]
|
||||
raw = (jwt_claims or {}).get(claim_name)
|
||||
value = str(raw) if raw else None
|
||||
|
||||
elif source == "litellm:user_id":
|
||||
uid = getattr(user_api_key_dict, "user_id", None)
|
||||
value = str(uid) if uid else None
|
||||
|
||||
elif source == "litellm:email":
|
||||
email = getattr(user_api_key_dict, "user_email", None)
|
||||
value = str(email) if email else None
|
||||
|
||||
elif source == "litellm:end_user_id":
|
||||
eid = getattr(user_api_key_dict, "end_user_id", None)
|
||||
value = str(eid) if eid else None
|
||||
|
||||
elif source == "litellm:team_id":
|
||||
tid = getattr(user_api_key_dict, "team_id", None)
|
||||
value = str(tid) if tid else None
|
||||
|
||||
else:
|
||||
verbose_proxy_logger.warning(
|
||||
"MCPJWTSigner: unknown end_user_claim_source %r — skipping", source
|
||||
)
|
||||
continue
|
||||
|
||||
if value:
|
||||
return value
|
||||
|
||||
# Final fallback for service accounts with no user identity
|
||||
token = getattr(user_api_key_dict, "token", None) or getattr(
|
||||
user_api_key_dict, "api_key", None
|
||||
)
|
||||
if token:
|
||||
return "apikey:" + hashlib.sha256(str(token).encode()).hexdigest()[:16]
|
||||
return "litellm-proxy"
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# FR-10: Scope building
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def _build_scope(self, raw_tool_name: str) -> str:
|
||||
"""
|
||||
Build the JWT scope string.
|
||||
|
||||
When allowed_scopes is configured: join them verbatim.
|
||||
Otherwise auto-generate minimal, least-privilege scopes:
|
||||
- Tool call → mcp:tools/call mcp:tools/<name>:call
|
||||
- No tool → mcp:tools/call mcp:tools/list
|
||||
|
||||
NOTE: tools/list is intentionally NOT granted on tool-call JWTs to
|
||||
prevent callers from enumerating tools they didn't ask to use.
|
||||
"""
|
||||
if self.allowed_scopes is not None:
|
||||
return " ".join(self.allowed_scopes)
|
||||
|
||||
tool_name = (
|
||||
re.sub(r"[^a-zA-Z0-9_\-]", "_", raw_tool_name) if raw_tool_name else ""
|
||||
)
|
||||
if tool_name:
|
||||
scopes = ["mcp:tools/call", f"mcp:tools/{tool_name}:call"]
|
||||
else:
|
||||
scopes = ["mcp:tools/call", "mcp:tools/list"]
|
||||
return " ".join(scopes)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# FR-13: Claim operations
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def _apply_claim_operations(self, claims: Dict[str, Any]) -> Dict[str, Any]:
|
||||
"""Apply add_claims, set_claims, and remove_claims to the claim dict."""
|
||||
# add_claims: insert only when key is absent
|
||||
for k, v in self.add_claims.items():
|
||||
if k not in claims:
|
||||
claims[k] = v
|
||||
|
||||
# set_claims: always override (highest priority)
|
||||
claims = {**claims, **self.set_claims}
|
||||
|
||||
# remove_claims: delete listed keys
|
||||
for k in self.remove_claims:
|
||||
claims.pop(k, None)
|
||||
|
||||
return claims
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# FR-15: optional_claims passthrough
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def _passthrough_optional_claims(
|
||||
self,
|
||||
claims: Dict[str, Any],
|
||||
jwt_claims: Optional[Dict[str, Any]],
|
||||
) -> Dict[str, Any]:
|
||||
"""Forward optional_claims from verified incoming token into the outbound JWT."""
|
||||
if not self.optional_claims or not jwt_claims:
|
||||
return claims
|
||||
for claim in self.optional_claims:
|
||||
if claim in jwt_claims and claim not in claims:
|
||||
claims[claim] = jwt_claims[claim]
|
||||
return claims
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Core JWT builder
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def _build_claims(
|
||||
self,
|
||||
user_api_key_dict: UserAPIKeyAuth,
|
||||
data: dict,
|
||||
jwt_claims: Optional[Dict[str, Any]] = None,
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
Build JWT claims for the outbound MCP access token.
|
||||
|
||||
Args:
|
||||
user_api_key_dict: LiteLLM auth context for the current request.
|
||||
data: Pre-call hook data dict (contains mcp_tool_name etc.).
|
||||
jwt_claims: Verified incoming IdP claims (FR-5), or LiteLLM-decoded
|
||||
jwt_claims if available. None for pure API-key requests.
|
||||
"""
|
||||
now = int(time.time())
|
||||
claims: Dict[str, Any] = {
|
||||
"iss": self.issuer,
|
||||
"aud": self.audience,
|
||||
"iat": now,
|
||||
"exp": now + self.ttl_seconds,
|
||||
"nbf": now,
|
||||
}
|
||||
|
||||
# sub — resolved via ordered claim sources (FR-12)
|
||||
claims["sub"] = self._resolve_end_user_identity(user_api_key_dict, jwt_claims)
|
||||
|
||||
# email passthrough when available from LiteLLM context
|
||||
user_email = getattr(user_api_key_dict, "user_email", None)
|
||||
if user_email:
|
||||
claims["email"] = user_email
|
||||
|
||||
# act — RFC 8693 delegation claim (team/org context)
|
||||
team_id = getattr(user_api_key_dict, "team_id", None)
|
||||
org_id = getattr(user_api_key_dict, "org_id", None)
|
||||
act_sub = team_id or org_id or "litellm-proxy"
|
||||
claims["act"] = {"sub": act_sub}
|
||||
|
||||
# end_user_id when set separately from user_id
|
||||
end_user_id = getattr(user_api_key_dict, "end_user_id", None)
|
||||
if end_user_id:
|
||||
claims["end_user_id"] = end_user_id
|
||||
|
||||
# scope (FR-10)
|
||||
raw_tool_name: str = data.get("mcp_tool_name", "")
|
||||
claims["scope"] = self._build_scope(raw_tool_name)
|
||||
|
||||
# optional_claims passthrough (FR-15)
|
||||
claims = self._passthrough_optional_claims(claims, jwt_claims)
|
||||
|
||||
# Claim operations — applied last so admin overrides take effect (FR-13)
|
||||
claims = self._apply_claim_operations(claims)
|
||||
|
||||
return claims
|
||||
|
||||
def _build_channel_token_claims(
|
||||
self,
|
||||
base_claims: Dict[str, Any],
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
Build claims for the channel token (FR-14 two-token model).
|
||||
|
||||
Inherits sub/act/scope from the access token but uses a separate
|
||||
audience and TTL so the transport layer and resource layer receive
|
||||
purpose-bound credentials.
|
||||
"""
|
||||
now = int(time.time())
|
||||
return {
|
||||
**base_claims,
|
||||
"aud": self.channel_token_audience,
|
||||
"iat": now,
|
||||
"exp": now + self.channel_token_ttl,
|
||||
"nbf": now,
|
||||
}
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# FR-9: Debug header
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
@staticmethod
|
||||
def _build_debug_header(claims: Dict[str, Any], kid: str) -> str:
|
||||
"""
|
||||
Build the x-litellm-mcp-debug header value.
|
||||
|
||||
Format: v=1; kid=<kid>; sub=<sub>; iss=<iss>; exp=<exp>; scope=<scope>
|
||||
Scope is truncated to 80 chars for header safety.
|
||||
"""
|
||||
sub = claims.get("sub", "")
|
||||
iss = claims.get("iss", "")
|
||||
exp = claims.get("exp", 0)
|
||||
scope = claims.get("scope", "")
|
||||
if len(scope) > 80:
|
||||
scope = scope[:77] + "..."
|
||||
return f"v=1; kid={kid}; sub={sub}; iss={iss}; exp={exp}; scope={scope}"
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Guardrail hook
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
@log_guardrail_information
|
||||
async def async_pre_call_hook(
|
||||
self,
|
||||
user_api_key_dict: UserAPIKeyAuth,
|
||||
cache: DualCache,
|
||||
data: dict,
|
||||
call_type: CallTypesLiteral,
|
||||
) -> Optional[Union[Exception, str, dict]]:
|
||||
"""
|
||||
Verifies the incoming token (when configured), validates required claims,
|
||||
then signs an outbound JWT and injects it as the Authorization header.
|
||||
|
||||
All non-MCP call types pass through unchanged.
|
||||
"""
|
||||
if call_type != "call_mcp_tool":
|
||||
return data
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# FR-5: Verify incoming token before re-signing
|
||||
# ------------------------------------------------------------------
|
||||
jwt_claims: Optional[Dict[str, Any]] = None
|
||||
raw_token: Optional[str] = data.get("incoming_bearer_token")
|
||||
|
||||
if self.access_token_discovery_uri and raw_token:
|
||||
# Three-dot pattern → JWT; otherwise opaque.
|
||||
is_jwt = raw_token.count(".") == 2
|
||||
try:
|
||||
if is_jwt:
|
||||
jwt_claims = await self._verify_incoming_jwt(raw_token)
|
||||
elif self.token_introspection_endpoint:
|
||||
jwt_claims = await self._introspect_opaque_token(raw_token)
|
||||
else:
|
||||
verbose_proxy_logger.warning(
|
||||
"MCPJWTSigner: access_token_discovery_uri is set but the "
|
||||
"incoming token appears to be opaque and no "
|
||||
"token_introspection_endpoint is configured. "
|
||||
"Proceeding without incoming token verification."
|
||||
)
|
||||
except Exception as exc:
|
||||
verbose_proxy_logger.error(
|
||||
"MCPJWTSigner: incoming token verification failed: %s", exc
|
||||
)
|
||||
from fastapi import HTTPException
|
||||
|
||||
raise HTTPException(
|
||||
status_code=401,
|
||||
detail={
|
||||
"error": (
|
||||
f"MCPJWTSigner: incoming token verification failed: {exc}"
|
||||
)
|
||||
},
|
||||
)
|
||||
elif not raw_token and self.access_token_discovery_uri:
|
||||
verbose_proxy_logger.debug(
|
||||
"MCPJWTSigner: access_token_discovery_uri configured but no Bearer "
|
||||
"token found in request (API-key auth request — skipping verification)."
|
||||
)
|
||||
|
||||
# Fall back to LiteLLM-decoded JWT claims (available when proxy uses JWT auth).
|
||||
if jwt_claims is None:
|
||||
jwt_claims = getattr(user_api_key_dict, "jwt_claims", None)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# FR-15: Validate required claims
|
||||
# ------------------------------------------------------------------
|
||||
self._validate_required_claims(jwt_claims)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Build outbound access token
|
||||
# ------------------------------------------------------------------
|
||||
claims = self._build_claims(user_api_key_dict, data, jwt_claims)
|
||||
|
||||
signed_token = jwt.encode(
|
||||
claims,
|
||||
self._private_key,
|
||||
algorithm=self.ALGORITHM,
|
||||
headers={"kid": self._kid},
|
||||
)
|
||||
|
||||
# Merge into existing extra_headers — a prior guardrail in the chain may
|
||||
# have already injected tracing headers or correlation IDs.
|
||||
existing_headers: Dict[str, str] = data.get("extra_headers") or {}
|
||||
new_headers: Dict[str, str] = {
|
||||
**existing_headers,
|
||||
"Authorization": f"Bearer {signed_token}",
|
||||
}
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# FR-14: Two-token model — channel token
|
||||
# ------------------------------------------------------------------
|
||||
if self.channel_token_audience:
|
||||
channel_claims = self._build_channel_token_claims(claims)
|
||||
channel_token = jwt.encode(
|
||||
channel_claims,
|
||||
self._private_key,
|
||||
algorithm=self.ALGORITHM,
|
||||
headers={"kid": self._kid},
|
||||
)
|
||||
new_headers["x-mcp-channel-token"] = f"Bearer {channel_token}"
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# FR-9: Debug header
|
||||
# ------------------------------------------------------------------
|
||||
if self.debug_headers:
|
||||
new_headers["x-litellm-mcp-debug"] = self._build_debug_header(
|
||||
claims, self._kid
|
||||
)
|
||||
|
||||
data["extra_headers"] = new_headers
|
||||
|
||||
verbose_proxy_logger.debug(
|
||||
"MCPJWTSigner: signed JWT sub=%s act=%s tool=%s exp=%d "
|
||||
"verified=%s channel=%s",
|
||||
claims.get("sub"),
|
||||
claims.get("act", {}).get("sub"),
|
||||
data.get("mcp_tool_name"),
|
||||
claims["exp"],
|
||||
jwt_claims is not None,
|
||||
bool(self.channel_token_audience),
|
||||
)
|
||||
|
||||
return data
|
||||
@@ -2142,8 +2142,7 @@ async def _resolve_org_filter_for_user_search(
|
||||
member_org_ids: List[str] = []
|
||||
if caller_user is not None:
|
||||
member_org_ids = [
|
||||
m.organization_id
|
||||
for m in (caller_user.organization_memberships or [])
|
||||
m.organization_id for m in (caller_user.organization_memberships or [])
|
||||
]
|
||||
|
||||
if member_org_ids:
|
||||
|
||||
@@ -1863,16 +1863,10 @@ async def _validate_update_key_data(
|
||||
user_api_key_cache: Any,
|
||||
) -> None:
|
||||
"""Validate permissions and constraints for key update."""
|
||||
_is_proxy_admin = (
|
||||
user_api_key_dict.user_role == LitellmUserRoles.PROXY_ADMIN.value
|
||||
)
|
||||
_is_proxy_admin = user_api_key_dict.user_role == LitellmUserRoles.PROXY_ADMIN.value
|
||||
|
||||
# Prevent non-admin from removing user_id (setting to empty string) (LIT-1884)
|
||||
if (
|
||||
data.user_id is not None
|
||||
and data.user_id == ""
|
||||
and not _is_proxy_admin
|
||||
):
|
||||
if data.user_id is not None and data.user_id == "" and not _is_proxy_admin:
|
||||
raise HTTPException(
|
||||
status_code=403,
|
||||
detail="Non-admin users cannot remove the user_id from a key.",
|
||||
|
||||
@@ -101,6 +101,7 @@ from litellm.types.proxy.management_endpoints.team_endpoints import (
|
||||
BulkTeamMemberAddRequest,
|
||||
BulkTeamMemberAddResponse,
|
||||
GetTeamMemberPermissionsResponse,
|
||||
TeamListItem,
|
||||
TeamListResponse,
|
||||
TeamMemberAddResult,
|
||||
UpdateTeamMemberPermissionsRequest,
|
||||
@@ -857,7 +858,13 @@ async def new_team( # noqa: PLR0915
|
||||
|
||||
# Apply defaults from litellm.default_team_params for any fields
|
||||
# not explicitly provided in the request.
|
||||
for field in ("max_budget", "budget_duration", "tpm_limit", "rpm_limit", "team_member_permissions"):
|
||||
for field in (
|
||||
"max_budget",
|
||||
"budget_duration",
|
||||
"tpm_limit",
|
||||
"rpm_limit",
|
||||
"team_member_permissions",
|
||||
):
|
||||
if getattr(data, field, None) is None:
|
||||
default_value = _get_default_team_param(field)
|
||||
if default_value is not None:
|
||||
@@ -3206,6 +3213,40 @@ async def list_available_teams(
|
||||
return available_teams_correct_type
|
||||
|
||||
|
||||
async def _get_org_admin_org_ids(
|
||||
user_id: str,
|
||||
prisma_client: Any,
|
||||
user_api_key_cache: Any,
|
||||
proxy_logging_obj: Any,
|
||||
) -> Optional[List[str]]:
|
||||
"""
|
||||
Return the list of organization IDs where the user is an org admin.
|
||||
Returns None if the user is not an org admin of any organization or if
|
||||
the user cannot be found.
|
||||
"""
|
||||
try:
|
||||
caller_user = await get_user_object(
|
||||
user_id=user_id,
|
||||
prisma_client=prisma_client,
|
||||
user_api_key_cache=user_api_key_cache,
|
||||
user_id_upsert=False,
|
||||
proxy_logging_obj=proxy_logging_obj,
|
||||
)
|
||||
except ValueError:
|
||||
# get_user_object raises ValueError when the user doesn't exist
|
||||
return None
|
||||
|
||||
if caller_user is None:
|
||||
return None
|
||||
|
||||
org_ids = [
|
||||
m.organization_id
|
||||
for m in (caller_user.organization_memberships or [])
|
||||
if m.user_role == LitellmUserRoles.ORG_ADMIN.value
|
||||
]
|
||||
return org_ids if org_ids else None
|
||||
|
||||
|
||||
async def _build_team_list_where_conditions(
|
||||
prisma_client: PrismaClient,
|
||||
team_id: Optional[str],
|
||||
@@ -3213,8 +3254,16 @@ async def _build_team_list_where_conditions(
|
||||
organization_id: Optional[str],
|
||||
user_id: Optional[str],
|
||||
use_deleted_table: bool,
|
||||
) -> Dict[str, Any]:
|
||||
"""Build where conditions for team list query."""
|
||||
org_admin_org_ids: Optional[List[str]] = None,
|
||||
user_api_key_cache: Optional[Any] = None,
|
||||
proxy_logging_obj: Optional[Any] = None,
|
||||
) -> Optional[Dict[str, Any]]:
|
||||
"""
|
||||
Build where conditions for team list query.
|
||||
|
||||
Returns None when the query is guaranteed to yield no results (e.g. user
|
||||
has no team memberships), allowing the caller to skip the DB round-trip.
|
||||
"""
|
||||
where_conditions: Dict[str, Any] = {}
|
||||
|
||||
if team_id:
|
||||
@@ -3228,58 +3277,79 @@ async def _build_team_list_where_conditions(
|
||||
|
||||
if organization_id:
|
||||
where_conditions["organization_id"] = organization_id
|
||||
elif org_admin_org_ids is not None and not user_id:
|
||||
# Org admin without explicit org or user filter: scope to their orgs.
|
||||
# NOTE: when user_id is provided, no org filter is applied — the
|
||||
# query returns all teams the target user belongs to across all
|
||||
# organisations. This matches the legacy /team/list behaviour in
|
||||
# _authorize_and_filter_teams which fetches direct-membership teams
|
||||
# without an org constraint.
|
||||
where_conditions["organization_id"] = {"in": org_admin_org_ids}
|
||||
|
||||
if user_id:
|
||||
try:
|
||||
user_object = await prisma_client.db.litellm_usertable.find_unique(
|
||||
where={"user_id": user_id}
|
||||
user_object_correct_type = await get_user_object(
|
||||
user_id=user_id,
|
||||
prisma_client=prisma_client,
|
||||
user_api_key_cache=user_api_key_cache,
|
||||
user_id_upsert=False,
|
||||
proxy_logging_obj=proxy_logging_obj,
|
||||
)
|
||||
except Exception:
|
||||
except ValueError:
|
||||
raise HTTPException(
|
||||
status_code=404,
|
||||
detail={"error": f"User not found, passed user_id={user_id}"},
|
||||
)
|
||||
if user_object is None:
|
||||
if user_object_correct_type is None:
|
||||
raise HTTPException(
|
||||
status_code=404,
|
||||
detail={"error": f"User not found, passed user_id={user_id}"},
|
||||
)
|
||||
user_object_correct_type = LiteLLM_UserTable(**user_object.model_dump())
|
||||
user_team_ids = user_object_correct_type.teams or []
|
||||
|
||||
if use_deleted_table:
|
||||
where_conditions["members"] = {"has": user_id}
|
||||
else:
|
||||
if team_id is None:
|
||||
where_conditions["team_id"] = {"in": user_object_correct_type.teams}
|
||||
elif team_id in user_object_correct_type.teams:
|
||||
where_conditions["team_id"] = team_id
|
||||
# When user_id is provided, filter by that user's direct team
|
||||
# memberships. For org admins the access control gate in
|
||||
# list_team_v2 already verified the caller's authority — the
|
||||
# filter logic is the same as for regular users.
|
||||
if not user_team_ids:
|
||||
return None # no memberships — skip the DB query
|
||||
elif team_id is not None:
|
||||
# team_id exact-match already in where_conditions; verify membership
|
||||
if team_id not in user_team_ids:
|
||||
raise HTTPException(
|
||||
status_code=404,
|
||||
detail={"error": f"User is not a member of team_id={team_id}"},
|
||||
)
|
||||
else:
|
||||
raise HTTPException(
|
||||
status_code=404,
|
||||
detail={"error": f"User is not a member of team_id={team_id}"},
|
||||
)
|
||||
where_conditions["team_id"] = {"in": user_team_ids}
|
||||
|
||||
return where_conditions
|
||||
|
||||
|
||||
def _convert_teams_to_response(
|
||||
teams: List[Any], use_deleted_table: bool
|
||||
) -> List[Union[LiteLLM_TeamTable, LiteLLM_DeletedTeamTable]]:
|
||||
"""Convert Prisma models to Pydantic models."""
|
||||
team_list: List[Union[LiteLLM_TeamTable, LiteLLM_DeletedTeamTable]] = []
|
||||
if teams:
|
||||
for team in teams:
|
||||
# Convert Prisma model to dict (supports both Pydantic v1 and v2)
|
||||
try:
|
||||
team_dict = team.model_dump()
|
||||
except Exception:
|
||||
# Fallback for Pydantic v1 compatibility
|
||||
team_dict = team.dict()
|
||||
if use_deleted_table:
|
||||
# Use deleted team type to preserve deleted_at, deleted_by, etc.
|
||||
team_list.append(LiteLLM_DeletedTeamTable(**team_dict))
|
||||
else:
|
||||
team_list.append(LiteLLM_TeamTable(**team_dict))
|
||||
def _convert_teams_to_response_models(
|
||||
teams: list,
|
||||
use_deleted_table: bool,
|
||||
) -> List[Union[TeamListItem, LiteLLM_TeamTable, LiteLLM_DeletedTeamTable]]:
|
||||
"""Convert raw Prisma team rows to response models."""
|
||||
team_list: List[Union[TeamListItem, LiteLLM_TeamTable, LiteLLM_DeletedTeamTable]] = []
|
||||
for team in teams:
|
||||
try:
|
||||
team_dict = team.model_dump()
|
||||
except Exception:
|
||||
team_dict = team.dict()
|
||||
|
||||
if use_deleted_table:
|
||||
team_list.append(LiteLLM_DeletedTeamTable(**team_dict))
|
||||
else:
|
||||
members_with_roles = team_dict.get("members_with_roles")
|
||||
if not isinstance(members_with_roles, list):
|
||||
members_with_roles = []
|
||||
team_dict["members_with_roles"] = members_with_roles
|
||||
members_count = len(members_with_roles)
|
||||
team_list.append(TeamListItem(**team_dict, members_count=members_count))
|
||||
return team_list
|
||||
|
||||
|
||||
@@ -3347,7 +3417,11 @@ async def list_team_v2(
|
||||
status: Optional[str]
|
||||
Filter by status. Currently supports "deleted" to query deleted teams.
|
||||
"""
|
||||
from litellm.proxy.proxy_server import prisma_client
|
||||
from litellm.proxy.proxy_server import (
|
||||
prisma_client,
|
||||
proxy_logging_obj,
|
||||
user_api_key_cache,
|
||||
)
|
||||
|
||||
if prisma_client is None:
|
||||
raise HTTPException(
|
||||
@@ -3355,20 +3429,55 @@ async def list_team_v2(
|
||||
detail={"error": f"No db connected. prisma client={prisma_client}"},
|
||||
)
|
||||
|
||||
if not allowed_route_check_inside_route(
|
||||
user_api_key_dict=user_api_key_dict, requested_user_id=user_id
|
||||
):
|
||||
raise HTTPException(
|
||||
status_code=401,
|
||||
detail={
|
||||
"error": "Only admin users can query all teams/other teams. Your user role={}".format(
|
||||
user_api_key_dict.user_role
|
||||
)
|
||||
},
|
||||
)
|
||||
# --- Access control ---
|
||||
# Proxy admins and admin viewers can query any teams.
|
||||
# Org admins can query teams within their organizations.
|
||||
# Regular users can only query their own teams.
|
||||
is_proxy_admin = _user_has_admin_view(user_api_key_dict)
|
||||
org_admin_org_ids: Optional[List[str]] = None
|
||||
|
||||
if user_id is None and user_api_key_dict.user_role != LitellmUserRoles.PROXY_ADMIN:
|
||||
user_id = user_api_key_dict.user_id
|
||||
if not is_proxy_admin:
|
||||
# Always check org admin status so that even own-queries see
|
||||
# the full set of organisation teams, not just direct memberships.
|
||||
if user_api_key_dict.user_id:
|
||||
org_admin_org_ids = await _get_org_admin_org_ids(
|
||||
user_id=user_api_key_dict.user_id,
|
||||
prisma_client=prisma_client,
|
||||
user_api_key_cache=user_api_key_cache,
|
||||
proxy_logging_obj=proxy_logging_obj,
|
||||
)
|
||||
|
||||
if org_admin_org_ids is not None:
|
||||
# Org admin: validate org_id filter if provided
|
||||
if organization_id and organization_id not in org_admin_org_ids:
|
||||
raise HTTPException(
|
||||
status_code=403,
|
||||
detail={
|
||||
"error": "You can only view teams within your organizations."
|
||||
},
|
||||
)
|
||||
verbose_proxy_logger.debug(
|
||||
"list_team_v2: org admin access for user=%s, org_ids=%s, user_id_filter=%s",
|
||||
user_api_key_dict.user_id,
|
||||
org_admin_org_ids,
|
||||
user_id,
|
||||
)
|
||||
else:
|
||||
# Not an org admin — fall back to standard route check
|
||||
if not allowed_route_check_inside_route(
|
||||
user_api_key_dict=user_api_key_dict, requested_user_id=user_id
|
||||
):
|
||||
raise HTTPException(
|
||||
status_code=401,
|
||||
detail={
|
||||
"error": "Only admin users can query all teams/other teams. Your user role={}".format(
|
||||
user_api_key_dict.user_role
|
||||
)
|
||||
},
|
||||
)
|
||||
# Regular user — auto-inject caller's user_id
|
||||
if user_id is None:
|
||||
user_id = user_api_key_dict.user_id
|
||||
|
||||
if status is not None and status != "deleted":
|
||||
raise HTTPException(
|
||||
@@ -3383,7 +3492,8 @@ async def list_team_v2(
|
||||
# Calculate skip and take for pagination
|
||||
skip = (page - 1) * page_size
|
||||
|
||||
# Build where conditions based on provided parameters
|
||||
# Build where conditions based on provided parameters.
|
||||
# Returns None when the query is guaranteed to yield no results.
|
||||
where_conditions = await _build_team_list_where_conditions(
|
||||
prisma_client=prisma_client,
|
||||
team_id=team_id,
|
||||
@@ -3391,8 +3501,20 @@ async def list_team_v2(
|
||||
organization_id=organization_id,
|
||||
user_id=user_id,
|
||||
use_deleted_table=use_deleted_table,
|
||||
org_admin_org_ids=org_admin_org_ids,
|
||||
user_api_key_cache=user_api_key_cache,
|
||||
proxy_logging_obj=proxy_logging_obj,
|
||||
)
|
||||
|
||||
if where_conditions is None:
|
||||
return {
|
||||
"teams": [],
|
||||
"total": 0,
|
||||
"page": page,
|
||||
"page_size": page_size,
|
||||
"total_pages": 0,
|
||||
}
|
||||
|
||||
# Build order_by conditions
|
||||
valid_sort_columns = ["team_id", "team_alias", "created_at"]
|
||||
order_by = None
|
||||
@@ -3428,8 +3550,8 @@ async def list_team_v2(
|
||||
# Calculate total pages
|
||||
total_pages = -(-total_count // page_size) # Ceiling division
|
||||
|
||||
# Convert Prisma models to Pydantic models, preserving deleted fields when applicable
|
||||
team_list = _convert_teams_to_response(teams, use_deleted_table)
|
||||
# Convert Prisma models to response models with members_count
|
||||
team_list = _convert_teams_to_response_models(teams, use_deleted_table)
|
||||
|
||||
return {
|
||||
"teams": team_list,
|
||||
|
||||
@@ -857,7 +857,10 @@ async def update_batch_in_database(
|
||||
# If the batch_processed column doesn't exist (old schema),
|
||||
# retry without it so the status update still succeeds.
|
||||
err_str = str(col_err).lower()
|
||||
if "batch_processed" in err_str and update_data.get("batch_processed") is not None:
|
||||
if (
|
||||
"batch_processed" in err_str
|
||||
and update_data.get("batch_processed") is not None
|
||||
):
|
||||
verbose_proxy_logger.warning(
|
||||
f"batch_processed column not found, retrying update without it: {col_err}"
|
||||
)
|
||||
|
||||
@@ -468,6 +468,12 @@ class ProxyInitializationHelpers:
|
||||
type=str,
|
||||
help="Path to the logging configuration file",
|
||||
)
|
||||
@click.option(
|
||||
"--setup",
|
||||
is_flag=True,
|
||||
default=False,
|
||||
help="Run the interactive setup wizard to configure providers and generate a config file",
|
||||
)
|
||||
@click.option(
|
||||
"--version",
|
||||
"-v",
|
||||
@@ -598,6 +604,7 @@ def run_server( # noqa: PLR0915
|
||||
num_requests,
|
||||
use_queue,
|
||||
health,
|
||||
setup,
|
||||
version,
|
||||
run_gunicorn,
|
||||
run_hypercorn,
|
||||
@@ -611,6 +618,12 @@ def run_server( # noqa: PLR0915
|
||||
max_requests_before_restart,
|
||||
enforce_prisma_migration_check: bool,
|
||||
):
|
||||
if setup:
|
||||
from litellm.setup_wizard import run_setup_wizard
|
||||
|
||||
run_setup_wizard()
|
||||
return
|
||||
|
||||
args = locals()
|
||||
if local:
|
||||
from proxy_server import (
|
||||
@@ -904,7 +917,7 @@ def run_server( # noqa: PLR0915
|
||||
# Auto-create PROMETHEUS_MULTIPROC_DIR for multi-worker setups
|
||||
ProxyInitializationHelpers._maybe_setup_prometheus_multiproc_dir(
|
||||
num_workers=num_workers,
|
||||
litellm_settings=litellm_settings if config else None,
|
||||
litellm_settings=litellm_settings if config else None, # type: ignore[possibly-unbound]
|
||||
)
|
||||
|
||||
# --- SEPARATE HEALTH APP LOGIC ---
|
||||
|
||||
@@ -115,7 +115,9 @@ async def background_streaming_task( # noqa: PLR0915
|
||||
UPDATE_INTERVAL = 0.150 # 150ms batching interval
|
||||
|
||||
# Track the terminal event from the stream (may not be "completed")
|
||||
terminal_status: Optional[ResponsesAPIStatus] = None # Will be set by response.completed/failed/incomplete/cancelled
|
||||
terminal_status: Optional[
|
||||
ResponsesAPIStatus
|
||||
] = None # Will be set by response.completed/failed/incomplete/cancelled
|
||||
terminal_error = None
|
||||
_event_to_status = {
|
||||
"response.completed": "completed",
|
||||
@@ -259,7 +261,10 @@ async def background_streaming_task( # noqa: PLR0915
|
||||
)
|
||||
|
||||
# Extract error for failed and incomplete responses
|
||||
if event_type == "response.failed" or event_type == "response.incomplete":
|
||||
if (
|
||||
event_type == "response.failed"
|
||||
or event_type == "response.incomplete"
|
||||
):
|
||||
terminal_error = response_data.get("error")
|
||||
|
||||
# Core response fields
|
||||
|
||||
@@ -146,6 +146,10 @@ model LiteLLM_TeamTable {
|
||||
litellm_model_table LiteLLM_ModelTable? @relation(fields: [model_id], references: [id])
|
||||
object_permission LiteLLM_ObjectPermissionTable? @relation(fields: [object_permission_id], references: [object_permission_id])
|
||||
projects LiteLLM_ProjectTable[]
|
||||
|
||||
@@index([organization_id])
|
||||
@@index([team_alias])
|
||||
@@index([created_at])
|
||||
}
|
||||
|
||||
// Projects sit between teams and keys for use-case management
|
||||
|
||||
@@ -40,9 +40,7 @@ def _get_registered_vantage_logger():
|
||||
return None
|
||||
|
||||
|
||||
async def _set_vantage_settings(
|
||||
api_key: str, integration_token: str, base_url: str
|
||||
):
|
||||
async def _set_vantage_settings(api_key: str, integration_token: str, base_url: str):
|
||||
"""Store Vantage settings in the database with encrypted API key."""
|
||||
from litellm.proxy.proxy_server import prisma_client
|
||||
|
||||
@@ -341,9 +339,7 @@ async def init_vantage_settings(
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
verbose_proxy_logger.error(
|
||||
f"Error initializing Vantage settings: {str(e)}"
|
||||
)
|
||||
verbose_proxy_logger.error(f"Error initializing Vantage settings: {str(e)}")
|
||||
raise HTTPException(
|
||||
status_code=500,
|
||||
detail={"error": f"Failed to initialize Vantage settings: {str(e)}"},
|
||||
@@ -395,7 +391,8 @@ async def vantage_dry_run_export(
|
||||
"""Cast Decimal columns to Float64 so .to_dicts() produces
|
||||
JSON-serializable float values instead of decimal.Decimal."""
|
||||
decimal_cols = [
|
||||
col for col, dtype in zip(frame.columns, frame.dtypes)
|
||||
col
|
||||
for col, dtype in zip(frame.columns, frame.dtypes)
|
||||
if isinstance(dtype, pl.Decimal)
|
||||
]
|
||||
if decimal_cols:
|
||||
@@ -404,8 +401,16 @@ async def vantage_dry_run_export(
|
||||
)
|
||||
return frame.to_dicts()
|
||||
|
||||
usage_sample = _to_json_safe_dicts(data.head(min(50, len(data)))) if not data.is_empty() else []
|
||||
normalized_sample = _to_json_safe_dicts(normalized.head(min(50, len(normalized)))) if not normalized.is_empty() else []
|
||||
usage_sample = (
|
||||
_to_json_safe_dicts(data.head(min(50, len(data))))
|
||||
if not data.is_empty()
|
||||
else []
|
||||
)
|
||||
normalized_sample = (
|
||||
_to_json_safe_dicts(normalized.head(min(50, len(normalized))))
|
||||
if not normalized.is_empty()
|
||||
else []
|
||||
)
|
||||
|
||||
# Use the same pre-transform column names as
|
||||
# FocusExportEngine.dry_run_export_usage_data for consistency.
|
||||
@@ -437,14 +442,10 @@ async def vantage_dry_run_export(
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
verbose_proxy_logger.error(
|
||||
f"Error performing Vantage dry run export: {str(e)}"
|
||||
)
|
||||
verbose_proxy_logger.error(f"Error performing Vantage dry run export: {str(e)}")
|
||||
raise HTTPException(
|
||||
status_code=500,
|
||||
detail={
|
||||
"error": f"Failed to perform Vantage dry run export: {str(e)}"
|
||||
},
|
||||
detail={"error": f"Failed to perform Vantage dry run export: {str(e)}"},
|
||||
)
|
||||
|
||||
|
||||
|
||||
+19
-4
@@ -454,8 +454,6 @@ class ProxyLogging:
|
||||
|
||||
for hook in PROXY_HOOKS:
|
||||
proxy_hook = get_proxy_hook(hook)
|
||||
import inspect
|
||||
|
||||
expected_args = inspect.getfullargspec(proxy_hook).args
|
||||
passed_in_args: Dict[str, Any] = {}
|
||||
if "internal_usage_cache" in expected_args:
|
||||
@@ -559,6 +557,10 @@ class ProxyLogging:
|
||||
"user_api_key_request_route": kwargs.get("user_api_key_request_route"),
|
||||
"mcp_tool_name": request_obj.tool_name, # Keep original for reference
|
||||
"mcp_arguments": request_obj.arguments, # Keep original for reference
|
||||
# Raw Bearer token from the original HTTP request — allows guardrails
|
||||
# (e.g. MCPJWTSigner) to independently verify the caller's identity
|
||||
# before re-signing an outbound token (FR-5 verify+re-sign).
|
||||
"incoming_bearer_token": kwargs.get("incoming_bearer_token"),
|
||||
}
|
||||
|
||||
return synthetic_data
|
||||
@@ -824,17 +826,30 @@ class ProxyLogging:
|
||||
) -> dict:
|
||||
"""
|
||||
Helper function to convert pre_call_hook response back to kwargs for MCP usage.
|
||||
|
||||
Supports:
|
||||
- modified_arguments: Override tool call arguments
|
||||
- extra_headers: Inject custom headers into the outbound MCP request
|
||||
"""
|
||||
if not response_data:
|
||||
return original_kwargs
|
||||
|
||||
# Apply any argument modifications from the hook response
|
||||
modified_kwargs = original_kwargs.copy()
|
||||
|
||||
# If the response contains modified arguments, apply them
|
||||
if response_data.get("modified_arguments"):
|
||||
modified_kwargs["arguments"] = response_data["modified_arguments"]
|
||||
|
||||
if response_data.get("extra_headers"):
|
||||
# Merge rather than replace — a prior guardrail in the chain may have
|
||||
# already injected headers (e.g. tracing IDs). Later guardrails win on
|
||||
# key collisions so that the most-specific guardrail (e.g. JWT signer)
|
||||
# takes precedence over earlier ones.
|
||||
existing = modified_kwargs.get("extra_headers") or {}
|
||||
modified_kwargs["extra_headers"] = {
|
||||
**existing,
|
||||
**response_data["extra_headers"],
|
||||
}
|
||||
|
||||
return modified_kwargs
|
||||
|
||||
async def process_pre_call_hook_response(self, response, data, call_type):
|
||||
|
||||
@@ -7,7 +7,9 @@ from litellm.types.videos.utils import encode_character_id_with_provider
|
||||
|
||||
def extract_model_from_target_model_names(target_model_names: Any) -> Optional[str]:
|
||||
if isinstance(target_model_names, str):
|
||||
target_model_names = [m.strip() for m in target_model_names.split(",") if m.strip()]
|
||||
target_model_names = [
|
||||
m.strip() for m in target_model_names.split(",") if m.strip()
|
||||
]
|
||||
elif not isinstance(target_model_names, list):
|
||||
return None
|
||||
return target_model_names[0] if target_model_names else None
|
||||
|
||||
+30
-30
@@ -692,11 +692,11 @@ def responses(
|
||||
return run_async_function(aresponses_api_with_mcp, **mcp_call_kwargs)
|
||||
|
||||
# get provider config
|
||||
responses_api_provider_config: Optional[BaseResponsesAPIConfig] = (
|
||||
ProviderConfigManager.get_provider_responses_api_config(
|
||||
model=model,
|
||||
provider=custom_llm_provider,
|
||||
)
|
||||
responses_api_provider_config: Optional[
|
||||
BaseResponsesAPIConfig
|
||||
] = ProviderConfigManager.get_provider_responses_api_config(
|
||||
model=model,
|
||||
provider=custom_llm_provider,
|
||||
)
|
||||
|
||||
local_vars.update(kwargs)
|
||||
@@ -908,11 +908,11 @@ def delete_responses(
|
||||
raise ValueError("custom_llm_provider is required but passed as None")
|
||||
|
||||
# get provider config
|
||||
responses_api_provider_config: Optional[BaseResponsesAPIConfig] = (
|
||||
ProviderConfigManager.get_provider_responses_api_config(
|
||||
model=None,
|
||||
provider=custom_llm_provider,
|
||||
)
|
||||
responses_api_provider_config: Optional[
|
||||
BaseResponsesAPIConfig
|
||||
] = ProviderConfigManager.get_provider_responses_api_config(
|
||||
model=None,
|
||||
provider=custom_llm_provider,
|
||||
)
|
||||
|
||||
if responses_api_provider_config is None:
|
||||
@@ -1089,11 +1089,11 @@ def get_responses(
|
||||
raise ValueError("custom_llm_provider is required but passed as None")
|
||||
|
||||
# get provider config
|
||||
responses_api_provider_config: Optional[BaseResponsesAPIConfig] = (
|
||||
ProviderConfigManager.get_provider_responses_api_config(
|
||||
model=None,
|
||||
provider=custom_llm_provider,
|
||||
)
|
||||
responses_api_provider_config: Optional[
|
||||
BaseResponsesAPIConfig
|
||||
] = ProviderConfigManager.get_provider_responses_api_config(
|
||||
model=None,
|
||||
provider=custom_llm_provider,
|
||||
)
|
||||
|
||||
if responses_api_provider_config is None:
|
||||
@@ -1247,11 +1247,11 @@ def list_input_items(
|
||||
if custom_llm_provider is None:
|
||||
raise ValueError("custom_llm_provider is required but passed as None")
|
||||
|
||||
responses_api_provider_config: Optional[BaseResponsesAPIConfig] = (
|
||||
ProviderConfigManager.get_provider_responses_api_config(
|
||||
model=None,
|
||||
provider=custom_llm_provider,
|
||||
)
|
||||
responses_api_provider_config: Optional[
|
||||
BaseResponsesAPIConfig
|
||||
] = ProviderConfigManager.get_provider_responses_api_config(
|
||||
model=None,
|
||||
provider=custom_llm_provider,
|
||||
)
|
||||
|
||||
if responses_api_provider_config is None:
|
||||
@@ -1406,11 +1406,11 @@ def cancel_responses(
|
||||
raise ValueError("custom_llm_provider is required but passed as None")
|
||||
|
||||
# get provider config
|
||||
responses_api_provider_config: Optional[BaseResponsesAPIConfig] = (
|
||||
ProviderConfigManager.get_provider_responses_api_config(
|
||||
model=None,
|
||||
provider=custom_llm_provider,
|
||||
)
|
||||
responses_api_provider_config: Optional[
|
||||
BaseResponsesAPIConfig
|
||||
] = ProviderConfigManager.get_provider_responses_api_config(
|
||||
model=None,
|
||||
provider=custom_llm_provider,
|
||||
)
|
||||
|
||||
if responses_api_provider_config is None:
|
||||
@@ -1594,11 +1594,11 @@ def compact_responses(
|
||||
raise ValueError("custom_llm_provider is required but passed as None")
|
||||
|
||||
# get provider config
|
||||
responses_api_provider_config: Optional[BaseResponsesAPIConfig] = (
|
||||
ProviderConfigManager.get_provider_responses_api_config(
|
||||
model=model,
|
||||
provider=custom_llm_provider,
|
||||
)
|
||||
responses_api_provider_config: Optional[
|
||||
BaseResponsesAPIConfig
|
||||
] = ProviderConfigManager.get_provider_responses_api_config(
|
||||
model=model,
|
||||
provider=custom_llm_provider,
|
||||
)
|
||||
|
||||
if responses_api_provider_config is None:
|
||||
|
||||
+17
-6
@@ -8611,6 +8611,7 @@ class Router:
|
||||
_model_info = deployment.get("model_info", {})
|
||||
|
||||
# see if we have the info for this model
|
||||
_deployment_model = None # per-deployment model name (avoids overwriting the outer `model` group name)
|
||||
try:
|
||||
base_model = _model_info.get("base_model", None)
|
||||
if base_model is None:
|
||||
@@ -8618,7 +8619,7 @@ class Router:
|
||||
model_info = self.get_router_model_info(
|
||||
deployment=deployment, received_model_name=model
|
||||
)
|
||||
model = base_model or _litellm_params.get("model", None)
|
||||
_deployment_model = base_model or _litellm_params.get("model", None)
|
||||
|
||||
if (
|
||||
isinstance(model_info, dict)
|
||||
@@ -8632,7 +8633,9 @@ class Router:
|
||||
_context_window_error = True
|
||||
_potential_error_str += (
|
||||
"Model={}, Max Input Tokens={}, Got={}".format(
|
||||
model, model_info["max_input_tokens"], input_tokens
|
||||
_deployment_model,
|
||||
model_info["max_input_tokens"],
|
||||
input_tokens,
|
||||
)
|
||||
)
|
||||
continue
|
||||
@@ -8688,13 +8691,21 @@ class Router:
|
||||
|
||||
## INVALID PARAMS ## -> catch 'gpt-3.5-turbo-16k' not supporting 'response_format' param
|
||||
if request_kwargs is not None and litellm.drop_params is False:
|
||||
# get supported params
|
||||
model, custom_llm_provider, _, _ = litellm.get_llm_provider(
|
||||
model=model, litellm_params=LiteLLM_Params(**_litellm_params)
|
||||
# get supported params — use per-deployment model to avoid overwriting the outer model group name
|
||||
_dep_model_for_params = _deployment_model or model
|
||||
(
|
||||
_dep_model_for_params,
|
||||
custom_llm_provider,
|
||||
_,
|
||||
_,
|
||||
) = litellm.get_llm_provider(
|
||||
model=_dep_model_for_params,
|
||||
litellm_params=LiteLLM_Params(**_litellm_params),
|
||||
)
|
||||
|
||||
supported_openai_params = litellm.get_supported_openai_params(
|
||||
model=model, custom_llm_provider=custom_llm_provider
|
||||
model=_dep_model_for_params,
|
||||
custom_llm_provider=custom_llm_provider,
|
||||
)
|
||||
|
||||
if supported_openai_params is None:
|
||||
|
||||
@@ -0,0 +1,668 @@
|
||||
# ruff: noqa: T201
|
||||
# flake8: noqa: T201
|
||||
"""
|
||||
LiteLLM Interactive Setup Wizard
|
||||
|
||||
Guides users through selecting LLM providers, entering API keys,
|
||||
and generating a proxy config file — mirroring the Claude Code onboarding UX.
|
||||
"""
|
||||
|
||||
import importlib.metadata
|
||||
import os
|
||||
import re
|
||||
import secrets
|
||||
import sys
|
||||
import sysconfig
|
||||
from pathlib import Path
|
||||
from typing import Dict, List, Optional, Set
|
||||
|
||||
# termios / tty are Unix-only; fall back gracefully on Windows
|
||||
try:
|
||||
import termios
|
||||
import tty
|
||||
|
||||
_HAS_RAW_TERMINAL: bool = True
|
||||
except ImportError:
|
||||
termios = None # type: ignore[assignment]
|
||||
tty = None # type: ignore[assignment]
|
||||
_HAS_RAW_TERMINAL = False
|
||||
|
||||
from litellm.utils import check_valid_key
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Provider definitions
|
||||
# ---------------------------------------------------------------------------
|
||||
# Each entry describes one provider card shown in the wizard.
|
||||
# `env_key` — primary env var name (None = no key needed, e.g. Ollama)
|
||||
# `test_model` — model passed to check_valid_key for credential validation
|
||||
# (None = skip validation, e.g. Azure needs a deployment name)
|
||||
# `models` — default models written into the generated config
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
PROVIDERS: List[Dict] = [
|
||||
{
|
||||
"id": "openai",
|
||||
"name": "OpenAI",
|
||||
"description": "GPT-4o, GPT-4o-mini, o3-mini",
|
||||
"env_key": "OPENAI_API_KEY",
|
||||
"key_hint": "sk-...",
|
||||
"test_model": "gpt-4o-mini",
|
||||
"models": ["gpt-4o", "gpt-4o-mini"],
|
||||
},
|
||||
{
|
||||
"id": "anthropic",
|
||||
"name": "Anthropic",
|
||||
"description": "Claude Opus 4.6, Sonnet 4.6, Haiku 4.5",
|
||||
"env_key": "ANTHROPIC_API_KEY",
|
||||
"key_hint": "sk-ant-...",
|
||||
"test_model": "claude-haiku-4-5-20251001",
|
||||
"models": ["claude-opus-4-6", "claude-sonnet-4-6", "claude-haiku-4-5-20251001"],
|
||||
},
|
||||
{
|
||||
"id": "gemini",
|
||||
"name": "Google Gemini",
|
||||
"description": "Gemini 2.0 Flash, Gemini 2.5 Pro",
|
||||
"env_key": "GEMINI_API_KEY",
|
||||
"key_hint": "AIza...",
|
||||
"test_model": "gemini/gemini-2.0-flash",
|
||||
"models": ["gemini/gemini-2.0-flash", "gemini/gemini-2.5-pro"],
|
||||
},
|
||||
{
|
||||
"id": "azure",
|
||||
"name": "Azure OpenAI",
|
||||
"description": "GPT-4o via Azure",
|
||||
"env_key": "AZURE_API_KEY",
|
||||
"key_hint": "your-azure-key",
|
||||
"test_model": None, # needs deployment name — skip validation
|
||||
"models": [],
|
||||
"needs_api_base": True,
|
||||
"api_base_hint": "https://<resource>.openai.azure.com/",
|
||||
"api_version": "2024-07-01-preview",
|
||||
},
|
||||
{
|
||||
"id": "bedrock",
|
||||
"name": "AWS Bedrock",
|
||||
"description": "Claude 3.5, Llama 3 via AWS",
|
||||
"env_key": "AWS_ACCESS_KEY_ID",
|
||||
"key_hint": "AKIA...",
|
||||
"test_model": None, # multi-key auth — skip validation
|
||||
"models": ["bedrock/anthropic.claude-3-5-sonnet-20241022-v2:0"],
|
||||
"extra_keys": ["AWS_SECRET_ACCESS_KEY", "AWS_REGION_NAME"],
|
||||
"extra_hints": ["your-secret-key", "us-east-1"],
|
||||
},
|
||||
{
|
||||
"id": "ollama",
|
||||
"name": "Ollama",
|
||||
"description": "Local models (llama3.2, mistral, etc.)",
|
||||
"env_key": None,
|
||||
"key_hint": None,
|
||||
"test_model": None, # local — no remote validation
|
||||
"models": ["ollama/llama3.2", "ollama/mistral"],
|
||||
"api_base": "http://localhost:11434",
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# ANSI colour helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
_ANSI_RE = re.compile(r"\033\[[^m]*m")
|
||||
|
||||
_ORANGE = "\033[38;2;215;119;87m"
|
||||
_DIM = "\033[2m"
|
||||
_BOLD = "\033[1m"
|
||||
_GREEN = "\033[38;2;78;186;101m"
|
||||
_BLUE = "\033[38;2;177;185;249m"
|
||||
_GREY = "\033[38;2;153;153;153m"
|
||||
_RESET = "\033[0m"
|
||||
_CHECK = "✔"
|
||||
_CROSS = "✘"
|
||||
|
||||
_CURSOR_HIDE = "\033[?25l"
|
||||
_CURSOR_SHOW = "\033[?25h"
|
||||
_MOVE_UP = "\033[{}A"
|
||||
|
||||
|
||||
def _supports_color() -> bool:
|
||||
return sys.stdout.isatty() and os.environ.get("NO_COLOR") is None
|
||||
|
||||
|
||||
def _c(code: str, text: str) -> str:
|
||||
return f"{code}{text}{_RESET}" if _supports_color() else text
|
||||
|
||||
|
||||
def orange(t: str) -> str:
|
||||
return _c(_ORANGE, t)
|
||||
|
||||
|
||||
def bold(t: str) -> str:
|
||||
return _c(_BOLD, t)
|
||||
|
||||
|
||||
def green(t: str) -> str:
|
||||
return _c(_GREEN, t)
|
||||
|
||||
|
||||
def blue(t: str) -> str:
|
||||
return _c(_BLUE, t)
|
||||
|
||||
|
||||
def grey(t: str) -> str:
|
||||
return _c(_GREY, t)
|
||||
|
||||
|
||||
def dim(t: str) -> str:
|
||||
return _c(_DIM, t)
|
||||
|
||||
|
||||
def _divider() -> str:
|
||||
"""Return a styled divider line (evaluated at call-time, not import-time)."""
|
||||
return dim(" " + "╌" * 74)
|
||||
|
||||
|
||||
def _styled_input(prompt: str) -> str:
|
||||
"""
|
||||
Like input() but wraps ANSI sequences in readline ignore markers
|
||||
(\\001...\\002) so readline correctly tracks the cursor column.
|
||||
In non-TTY contexts, strips ANSI entirely so no escape codes appear.
|
||||
"""
|
||||
if sys.stdout.isatty():
|
||||
rl_prompt = _ANSI_RE.sub(lambda m: f"\001{m.group()}\002", prompt)
|
||||
else:
|
||||
rl_prompt = _ANSI_RE.sub("", prompt)
|
||||
return input(rl_prompt).strip()
|
||||
|
||||
|
||||
def _yaml_escape(value: str) -> str:
|
||||
"""Escape a string for safe embedding in a double-quoted YAML scalar."""
|
||||
return (
|
||||
value.replace("\\", "\\\\")
|
||||
.replace('"', '\\"')
|
||||
.replace("\n", "\\n")
|
||||
.replace("\r", "\\r")
|
||||
.replace("\t", "\\t")
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Layout constants
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
LITELLM_ASCII = r"""
|
||||
██╗ ██╗████████╗███████╗██╗ ██╗ ███╗ ███╗
|
||||
██║ ██║╚══██╔══╝██╔════╝██║ ██║ ████╗ ████║
|
||||
██║ ██║ ██║ █████╗ ██║ ██║ ██╔████╔██║
|
||||
██║ ██║ ██║ ██╔══╝ ██║ ██║ ██║╚██╔╝██║
|
||||
███████╗██║ ██║ ███████╗███████╗███████╗██║ ╚═╝ ██║
|
||||
╚══════╝╚═╝ ╚═╝ ╚══════╝╚══════╝╚══════╝╚═╝ ╚═╝
|
||||
"""
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Setup wizard
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class SetupWizard:
|
||||
"""
|
||||
Interactive onboarding wizard: provider selection → API keys → config file.
|
||||
|
||||
All methods are static — the class is purely a namespace with clear
|
||||
single-responsibility sections. Entry point: SetupWizard.run().
|
||||
"""
|
||||
|
||||
# ── entry point ─────────────────────────────────────────────────────────
|
||||
|
||||
@staticmethod
|
||||
def run() -> None:
|
||||
try:
|
||||
SetupWizard._wizard()
|
||||
except (KeyboardInterrupt, EOFError):
|
||||
print(f"\n\n {grey('Setup cancelled.')}\n")
|
||||
|
||||
# ── wizard steps ────────────────────────────────────────────────────────
|
||||
|
||||
@staticmethod
|
||||
def _wizard() -> None:
|
||||
SetupWizard._print_welcome()
|
||||
print(f" {bold('Lets get started.')}")
|
||||
print()
|
||||
|
||||
providers = SetupWizard._select_providers()
|
||||
env_vars = SetupWizard._collect_keys(providers)
|
||||
port, master_key = SetupWizard._proxy_settings()
|
||||
|
||||
config_path = Path(os.getcwd()) / "litellm_config.yaml"
|
||||
try:
|
||||
config_path.write_text(
|
||||
SetupWizard._build_config(providers, env_vars, master_key)
|
||||
)
|
||||
except OSError as exc:
|
||||
print(f"\n {bold(_CROSS + ' Could not write config:')} {exc}")
|
||||
print(" Try running from a directory you have write access to.\n")
|
||||
return
|
||||
|
||||
SetupWizard._print_success(config_path, port, master_key)
|
||||
SetupWizard._offer_start(config_path, port, master_key)
|
||||
|
||||
# ── welcome ─────────────────────────────────────────────────────────────
|
||||
|
||||
@staticmethod
|
||||
def _print_welcome() -> None:
|
||||
try:
|
||||
version = importlib.metadata.version("litellm")
|
||||
except Exception:
|
||||
version = "unknown"
|
||||
print()
|
||||
print(orange(LITELLM_ASCII.rstrip("\n")))
|
||||
print(f" {orange('Welcome')} to {bold('LiteLLM')} {grey('v' + version)}")
|
||||
print()
|
||||
print(_divider())
|
||||
print()
|
||||
|
||||
# ── provider selector ───────────────────────────────────────────────────
|
||||
|
||||
@staticmethod
|
||||
def _select_providers() -> List[Dict]:
|
||||
"""Arrow-key multi-select. Falls back to number input if /dev/tty unavailable."""
|
||||
if not _HAS_RAW_TERMINAL:
|
||||
return SetupWizard._select_fallback()
|
||||
try:
|
||||
return SetupWizard._select_interactive()
|
||||
except OSError:
|
||||
return SetupWizard._select_fallback()
|
||||
|
||||
@staticmethod
|
||||
def _read_key() -> str:
|
||||
"""Read one keypress from /dev/tty in raw mode."""
|
||||
assert (
|
||||
termios is not None and tty is not None
|
||||
) # only called when _HAS_RAW_TERMINAL
|
||||
with open("/dev/tty", "rb") as tty_fh:
|
||||
fd = tty_fh.fileno()
|
||||
old = termios.tcgetattr(fd)
|
||||
try:
|
||||
tty.setraw(fd)
|
||||
ch = tty_fh.read(1)
|
||||
if ch == b"\x1b":
|
||||
ch2 = tty_fh.read(1)
|
||||
if ch2 == b"[":
|
||||
ch3 = tty_fh.read(1)
|
||||
return "\x1b[" + ch3.decode("utf-8", errors="replace")
|
||||
return "\x1b" + ch2.decode("utf-8", errors="replace")
|
||||
return ch.decode("utf-8", errors="replace")
|
||||
finally:
|
||||
termios.tcsetattr(fd, termios.TCSADRAIN, old)
|
||||
|
||||
@staticmethod
|
||||
def _render_selector(cursor: int, selected: Set[int], first_render: bool) -> int:
|
||||
"""Draw or redraw the provider list. Returns the number of lines printed."""
|
||||
lines = [
|
||||
f"\n {bold('Add your first model')}\n",
|
||||
grey(" ↑↓ to navigate · Space to select · Enter to confirm") + "\n",
|
||||
"\n",
|
||||
]
|
||||
for i, p in enumerate(PROVIDERS):
|
||||
arrow = blue("❯") if i == cursor else " "
|
||||
bullet = green("◉") if i in selected else grey("○")
|
||||
name_str = bold(p["name"]) if i == cursor else p["name"]
|
||||
lines.append(f" {arrow} {bullet} {name_str} {grey(p['description'])}\n")
|
||||
lines.append("\n")
|
||||
|
||||
content = "".join(lines)
|
||||
if not first_render and _supports_color():
|
||||
sys.stdout.write(_MOVE_UP.format(content.count("\n")))
|
||||
sys.stdout.write(content)
|
||||
sys.stdout.flush()
|
||||
return content.count("\n")
|
||||
|
||||
@staticmethod
|
||||
def _select_interactive() -> List[Dict]:
|
||||
cursor = 0
|
||||
selected: set[int] = set()
|
||||
|
||||
if _supports_color():
|
||||
sys.stdout.write(_CURSOR_HIDE)
|
||||
sys.stdout.flush()
|
||||
try:
|
||||
SetupWizard._render_selector(cursor, selected, first_render=True)
|
||||
while True:
|
||||
key = SetupWizard._read_key()
|
||||
dirty = False
|
||||
if key == "\x1b[A":
|
||||
cursor = (cursor - 1) % len(PROVIDERS)
|
||||
dirty = True
|
||||
elif key == "\x1b[B":
|
||||
cursor = (cursor + 1) % len(PROVIDERS)
|
||||
dirty = True
|
||||
elif key == " ":
|
||||
selected.symmetric_difference_update({cursor})
|
||||
dirty = True
|
||||
elif key in ("\r", "\n"):
|
||||
if not selected:
|
||||
selected.add(cursor)
|
||||
break
|
||||
elif key in ("\x03", "\x04"):
|
||||
raise KeyboardInterrupt
|
||||
if dirty:
|
||||
SetupWizard._render_selector(cursor, selected, first_render=False)
|
||||
finally:
|
||||
if _supports_color():
|
||||
sys.stdout.write(_CURSOR_SHOW)
|
||||
sys.stdout.flush()
|
||||
|
||||
return [PROVIDERS[i] for i in sorted(selected)]
|
||||
|
||||
@staticmethod
|
||||
def _select_fallback() -> List[Dict]:
|
||||
"""Number-based fallback when raw terminal input is unavailable."""
|
||||
print()
|
||||
print(f" {bold('Add your first model')}")
|
||||
print(
|
||||
grey(
|
||||
" Enter numbers separated by commas (e.g. 1,2). Press Enter to confirm."
|
||||
)
|
||||
)
|
||||
print()
|
||||
for i, p in enumerate(PROVIDERS, 1):
|
||||
print(f" {grey(str(i) + '.')} {bold(p['name'])} {grey(p['description'])}")
|
||||
print()
|
||||
|
||||
while True:
|
||||
raw = _styled_input(f" {blue('❯')} Provider(s): ")
|
||||
if not raw:
|
||||
print(grey(" Please select at least one provider."))
|
||||
continue
|
||||
try:
|
||||
nums = [
|
||||
int(x.strip())
|
||||
for x in raw.replace(" ", ",").split(",")
|
||||
if x.strip()
|
||||
]
|
||||
valid = sorted({n for n in nums if 1 <= n <= len(PROVIDERS)})
|
||||
if not valid:
|
||||
print(grey(f" Enter numbers between 1 and {len(PROVIDERS)}."))
|
||||
continue
|
||||
return [PROVIDERS[i - 1] for i in valid]
|
||||
except ValueError:
|
||||
print(grey(" Enter numbers separated by commas, e.g. 1,3"))
|
||||
|
||||
# ── key collection ───────────────────────────────────────────────────────
|
||||
|
||||
@staticmethod
|
||||
def _collect_keys(providers: List[Dict]) -> Dict[str, str]:
|
||||
env_vars: Dict[str, str] = {}
|
||||
print()
|
||||
print(_divider())
|
||||
print()
|
||||
print(f" {bold('Enter your API keys')}")
|
||||
print(grey(" Keys are stored only in the generated config file."))
|
||||
print(
|
||||
grey(
|
||||
" Tip: add litellm_config.yaml to .gitignore to avoid committing secrets."
|
||||
)
|
||||
)
|
||||
print()
|
||||
|
||||
for p in providers:
|
||||
if p["env_key"] is None:
|
||||
print(
|
||||
f" {green(p['name'])}: {grey('no key needed (uses local Ollama)')}"
|
||||
)
|
||||
continue
|
||||
|
||||
key = SetupWizard._prompt_key(p)
|
||||
if not key:
|
||||
continue
|
||||
|
||||
for extra_key, extra_hint in zip(
|
||||
p.get("extra_keys", []), p.get("extra_hints", [])
|
||||
):
|
||||
val = _styled_input(f" {blue('❯')} {extra_key} {grey(extra_hint)}: ")
|
||||
if val:
|
||||
env_vars[extra_key] = val
|
||||
|
||||
if p.get("needs_api_base"):
|
||||
api_base = _styled_input(
|
||||
f" {blue('❯')} Azure endpoint URL {grey(p.get('api_base_hint', ''))}: "
|
||||
)
|
||||
if api_base:
|
||||
env_vars[f"_LITELLM_AZURE_API_BASE_{p['id'].upper()}"] = api_base
|
||||
deployment = _styled_input(
|
||||
f" {blue('❯')} Azure deployment name {grey('(e.g. my-gpt4o)')}: "
|
||||
)
|
||||
if deployment:
|
||||
env_vars[
|
||||
f"_LITELLM_AZURE_DEPLOYMENT_{p['id'].upper()}"
|
||||
] = deployment
|
||||
|
||||
# Store the key returned by validation — may be a re-entered replacement
|
||||
env_vars[p["env_key"]] = SetupWizard._validate_and_report(p, key)
|
||||
|
||||
return env_vars
|
||||
|
||||
@staticmethod
|
||||
def _prompt_key(provider: Dict) -> str:
|
||||
"""Prompt for a provider's API key, with skip option. Returns the key or ''."""
|
||||
hint = grey(provider.get("key_hint", ""))
|
||||
while True:
|
||||
key = _styled_input(
|
||||
f" {blue('❯')} {bold(provider['name'])} API key {hint}: "
|
||||
)
|
||||
if key:
|
||||
return key
|
||||
print(grey(" Key is required. Leave blank to skip this provider."))
|
||||
if _styled_input(grey(" Skip? (y/N): ")).lower() == "y":
|
||||
return ""
|
||||
|
||||
@staticmethod
|
||||
def _validate_and_report(provider: Dict, api_key: str) -> str:
|
||||
"""
|
||||
Validate credentials using litellm.utils.check_valid_key and print result.
|
||||
Offers a re-entry loop on failure. Returns the final (possibly re-entered) key.
|
||||
"""
|
||||
test_model: Optional[str] = provider.get("test_model")
|
||||
if not test_model:
|
||||
return api_key # Azure / Bedrock / Ollama — skip validation
|
||||
|
||||
while True:
|
||||
print(
|
||||
f" {grey('Testing connection to ' + provider['name'] + '...')}",
|
||||
flush=True,
|
||||
)
|
||||
valid = check_valid_key(model=test_model, api_key=api_key)
|
||||
if valid:
|
||||
print(
|
||||
f" {green(_CHECK)} {bold(provider['name'])} connected successfully"
|
||||
)
|
||||
return api_key
|
||||
|
||||
print(f" {_CROSS} {bold(provider['name'])} {grey('— invalid API key')}")
|
||||
if (
|
||||
_styled_input(f" {blue('❯')} Re-enter key? {grey('(y/N)')}: ").lower()
|
||||
!= "y"
|
||||
):
|
||||
return api_key
|
||||
|
||||
hint = grey(provider.get("key_hint", ""))
|
||||
new_key = _styled_input(
|
||||
f" {blue('❯')} {bold(provider['name'])} API key {hint}: "
|
||||
)
|
||||
if not new_key:
|
||||
return api_key
|
||||
api_key = new_key
|
||||
|
||||
# ── proxy settings ───────────────────────────────────────────────────────
|
||||
|
||||
@staticmethod
|
||||
def _proxy_settings() -> "tuple[int, str]":
|
||||
print()
|
||||
print(_divider())
|
||||
print()
|
||||
print(f" {bold('Proxy settings')}")
|
||||
print()
|
||||
port = 4000
|
||||
while True:
|
||||
port_raw = _styled_input(f" {blue('❯')} Port {grey('[4000]')}: ")
|
||||
if not port_raw:
|
||||
break
|
||||
if port_raw.isdigit() and 1 <= int(port_raw) <= 65535:
|
||||
port = int(port_raw)
|
||||
break
|
||||
print(grey(" Enter a valid port number (1–65535)."))
|
||||
key_raw = _styled_input(f" {blue('❯')} Master key {grey('[auto-generate]')}: ")
|
||||
master_key = key_raw if key_raw else f"sk-{secrets.token_urlsafe(32)}"
|
||||
return port, master_key
|
||||
|
||||
# ── config generation ────────────────────────────────────────────────────
|
||||
|
||||
@staticmethod
|
||||
def _build_config(
|
||||
providers: List[Dict],
|
||||
env_vars: Dict[str, str],
|
||||
master_key: str,
|
||||
) -> str:
|
||||
env_copy = dict(env_vars) # work on a copy — do not mutate caller's dict
|
||||
lines = ["model_list:"]
|
||||
for p in providers:
|
||||
# Only emit models for providers that actually have credentials
|
||||
has_creds = p["env_key"] is None or p["env_key"] in env_copy
|
||||
if not has_creds:
|
||||
continue
|
||||
|
||||
if p["id"] == "azure":
|
||||
deployment = env_copy.pop(
|
||||
f"_LITELLM_AZURE_DEPLOYMENT_{p['id'].upper()}", ""
|
||||
)
|
||||
if not deployment:
|
||||
continue # skip Azure entirely if no deployment name was provided
|
||||
models = [f"azure/{deployment}"]
|
||||
else:
|
||||
models = p["models"]
|
||||
|
||||
for model in models:
|
||||
raw_display = model.split("/")[-1] if "/" in model else model
|
||||
# Qualify azure display names to avoid collision with OpenAI model names
|
||||
display = f"azure-{raw_display}" if p["id"] == "azure" else raw_display
|
||||
lines += [
|
||||
f" - model_name: {display}",
|
||||
" litellm_params:",
|
||||
f" model: {model}",
|
||||
]
|
||||
if p["env_key"] and p["env_key"] in env_copy:
|
||||
lines.append(f" api_key: os.environ/{p['env_key']}")
|
||||
if p.get("api_base"):
|
||||
lines.append(
|
||||
f' api_base: "{_yaml_escape(str(p["api_base"]))}"'
|
||||
)
|
||||
elif p.get("needs_api_base"):
|
||||
azure_base_key = f"_LITELLM_AZURE_API_BASE_{p['id'].upper()}"
|
||||
if azure_base_key in env_copy:
|
||||
lines.append(
|
||||
f' api_base: "{_yaml_escape(env_copy.pop(azure_base_key))}"'
|
||||
)
|
||||
if p.get("api_version"):
|
||||
lines.append(f" api_version: {p['api_version']}")
|
||||
|
||||
lines += [
|
||||
"",
|
||||
"general_settings:",
|
||||
f' master_key: "{_yaml_escape(master_key)}"',
|
||||
"",
|
||||
]
|
||||
|
||||
real_vars = {k: v for k, v in env_copy.items() if not k.startswith("_LITELLM_")}
|
||||
if real_vars:
|
||||
lines.append("environment_variables:")
|
||||
for k, v in real_vars.items():
|
||||
lines.append(f' {k}: "{_yaml_escape(v)}"')
|
||||
lines.append("")
|
||||
|
||||
return "\n".join(lines)
|
||||
|
||||
# ── success + launch ─────────────────────────────────────────────────────
|
||||
|
||||
@staticmethod
|
||||
def _print_success(config_path: Path, port: int, master_key: str) -> None:
|
||||
print()
|
||||
print(_divider())
|
||||
print()
|
||||
print(f" {green(_CHECK + ' Config saved')} → {bold(str(config_path))}")
|
||||
print()
|
||||
print(f" {bold('To start your proxy:')}")
|
||||
print()
|
||||
print(f" {grey('$')} litellm --config {config_path} --port {port}")
|
||||
print()
|
||||
print(f" {bold('Then set your client:')}")
|
||||
print()
|
||||
print(f" export OPENAI_BASE_URL=http://localhost:{port}")
|
||||
print(f" export OPENAI_API_KEY={master_key}")
|
||||
print()
|
||||
print(_divider())
|
||||
print()
|
||||
|
||||
@staticmethod
|
||||
def _offer_start(config_path: Path, port: int, master_key: str) -> None:
|
||||
start = _styled_input(
|
||||
f" {blue('❯')} Start the proxy now? {grey('(Y/n)')}: "
|
||||
).lower()
|
||||
if start not in ("", "y", "yes"):
|
||||
print()
|
||||
print(
|
||||
f" Run {bold(f'litellm --config {config_path}')} whenever you're ready."
|
||||
)
|
||||
print()
|
||||
print(
|
||||
grey(f" Quick test once running: curl http://localhost:{port}/health")
|
||||
)
|
||||
print()
|
||||
return
|
||||
|
||||
print()
|
||||
print(_divider())
|
||||
print()
|
||||
print(f" {bold('Proxy is starting on')} http://localhost:{port}")
|
||||
print()
|
||||
print(grey(" Your proxy is OpenAI-compatible. Point any OpenAI SDK at it:"))
|
||||
print()
|
||||
print(f" export OPENAI_BASE_URL=http://localhost:{port}")
|
||||
print(f" export OPENAI_API_KEY={master_key}")
|
||||
print()
|
||||
print(grey(" Quick test (in another terminal):"))
|
||||
print()
|
||||
print(f" curl http://localhost:{port}/health")
|
||||
print()
|
||||
print(grey(" Dashboard:"))
|
||||
print()
|
||||
print(f" http://localhost:{port}/ui {grey('(login with your master key)')}")
|
||||
print()
|
||||
print(_divider())
|
||||
print()
|
||||
print(f" {green(_CHECK)} Starting… {grey('(Ctrl+C to stop)')}")
|
||||
print()
|
||||
|
||||
scripts_dir = sysconfig.get_path("scripts")
|
||||
litellm_bin = os.path.join(scripts_dir or "", "litellm")
|
||||
try:
|
||||
os.execlp(
|
||||
litellm_bin,
|
||||
litellm_bin,
|
||||
"--config",
|
||||
str(config_path),
|
||||
"--port",
|
||||
str(port),
|
||||
) # noqa: S606
|
||||
except OSError as exc:
|
||||
print(f"\n {bold(_CROSS + ' Could not start proxy:')} {exc}")
|
||||
print(f" Run manually: litellm --config {config_path} --port {port}\n")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Public entrypoint
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def run_setup_wizard() -> None:
|
||||
"""Run the interactive setup wizard. Called by `litellm --setup`."""
|
||||
SetupWizard.run()
|
||||
@@ -79,6 +79,7 @@ class SupportedGuardrailIntegrations(Enum):
|
||||
SEMANTIC_GUARD = "semantic_guard"
|
||||
MCP_END_USER_PERMISSION = "mcp_end_user_permission"
|
||||
BLOCK_CODE_EXECUTION = "block_code_execution"
|
||||
MCP_JWT_SIGNER = "mcp_jwt_signer"
|
||||
|
||||
|
||||
class Role(Enum):
|
||||
|
||||
@@ -43,10 +43,16 @@ class UpdateTeamMemberPermissionsRequest(BaseModel):
|
||||
team_member_permissions: List[str]
|
||||
|
||||
|
||||
class TeamListItem(LiteLLM_TeamTable):
|
||||
"""A team item in the paginated list response, enriched with computed fields."""
|
||||
|
||||
members_count: int = 0
|
||||
|
||||
|
||||
class TeamListResponse(BaseModel):
|
||||
"""Response to get the list of teams"""
|
||||
|
||||
teams: List[Union[LiteLLM_TeamTable, LiteLLM_DeletedTeamTable]]
|
||||
teams: List[Union[TeamListItem, LiteLLM_TeamTable, LiteLLM_DeletedTeamTable]]
|
||||
total: int
|
||||
page: int
|
||||
page_size: int
|
||||
|
||||
@@ -39,7 +39,8 @@ class VantageExportRequest(BaseModel):
|
||||
"""Request model for Vantage export operations (actual export, no default limit)"""
|
||||
|
||||
limit: Optional[int] = Field(
|
||||
None, description="Optional limit on number of records to export (default: no limit)"
|
||||
None,
|
||||
description="Optional limit on number of records to export (default: no limit)",
|
||||
)
|
||||
start_time_utc: Optional[datetime] = Field(
|
||||
None, description="Start time for data export in UTC"
|
||||
|
||||
@@ -195,7 +195,9 @@ def decode_character_id_with_provider(encoded_character_id: str) -> DecodedChara
|
||||
character_id=decoded_character_id,
|
||||
)
|
||||
except Exception as e:
|
||||
verbose_logger.debug(f"Error decoding character_id '{encoded_character_id}': {e}")
|
||||
verbose_logger.debug(
|
||||
f"Error decoding character_id '{encoded_character_id}': {e}"
|
||||
)
|
||||
return DecodedCharacterId(
|
||||
custom_llm_provider=None,
|
||||
model_id=None,
|
||||
|
||||
+26
-8
@@ -1186,13 +1186,17 @@ def video_create_character(
|
||||
|
||||
litellm_params = GenericLiteLLMParams(**kwargs)
|
||||
|
||||
provider_config: Optional[BaseVideoConfig] = ProviderConfigManager.get_provider_video_config(
|
||||
provider_config: Optional[
|
||||
BaseVideoConfig
|
||||
] = ProviderConfigManager.get_provider_video_config(
|
||||
model=None,
|
||||
provider=litellm.LlmProviders(custom_llm_provider),
|
||||
)
|
||||
|
||||
if provider_config is None:
|
||||
raise ValueError(f"video create character is not supported for {custom_llm_provider}")
|
||||
raise ValueError(
|
||||
f"video create character is not supported for {custom_llm_provider}"
|
||||
)
|
||||
|
||||
local_vars.update(kwargs)
|
||||
request_params: Dict = {"name": name}
|
||||
@@ -1311,13 +1315,17 @@ def video_get_character(
|
||||
|
||||
litellm_params = GenericLiteLLMParams(**kwargs)
|
||||
|
||||
provider_config: Optional[BaseVideoConfig] = ProviderConfigManager.get_provider_video_config(
|
||||
provider_config: Optional[
|
||||
BaseVideoConfig
|
||||
] = ProviderConfigManager.get_provider_video_config(
|
||||
model=None,
|
||||
provider=litellm.LlmProviders(custom_llm_provider),
|
||||
)
|
||||
|
||||
if provider_config is None:
|
||||
raise ValueError(f"video get character is not supported for {custom_llm_provider}")
|
||||
raise ValueError(
|
||||
f"video get character is not supported for {custom_llm_provider}"
|
||||
)
|
||||
|
||||
local_vars.update(kwargs)
|
||||
request_params: Dict = {"character_id": character_id}
|
||||
@@ -1439,7 +1447,9 @@ def video_edit(
|
||||
|
||||
litellm_params = GenericLiteLLMParams(**kwargs)
|
||||
|
||||
provider_config: Optional[BaseVideoConfig] = ProviderConfigManager.get_provider_video_config(
|
||||
provider_config: Optional[
|
||||
BaseVideoConfig
|
||||
] = ProviderConfigManager.get_provider_video_config(
|
||||
model=None,
|
||||
provider=litellm.LlmProviders(custom_llm_provider),
|
||||
)
|
||||
@@ -1572,16 +1582,24 @@ def video_extension(
|
||||
|
||||
litellm_params = GenericLiteLLMParams(**kwargs)
|
||||
|
||||
provider_config: Optional[BaseVideoConfig] = ProviderConfigManager.get_provider_video_config(
|
||||
provider_config: Optional[
|
||||
BaseVideoConfig
|
||||
] = ProviderConfigManager.get_provider_video_config(
|
||||
model=None,
|
||||
provider=litellm.LlmProviders(custom_llm_provider),
|
||||
)
|
||||
|
||||
if provider_config is None:
|
||||
raise ValueError(f"video extension is not supported for {custom_llm_provider}")
|
||||
raise ValueError(
|
||||
f"video extension is not supported for {custom_llm_provider}"
|
||||
)
|
||||
|
||||
local_vars.update(kwargs)
|
||||
request_params: Dict = {"video_id": video_id, "prompt": prompt, "seconds": seconds}
|
||||
request_params: Dict = {
|
||||
"video_id": video_id,
|
||||
"prompt": prompt,
|
||||
"seconds": seconds,
|
||||
}
|
||||
|
||||
litellm_logging_obj.update_environment_variables(
|
||||
model="",
|
||||
|
||||
@@ -32354,6 +32354,53 @@
|
||||
"supports_vision": true,
|
||||
"supports_web_search": true
|
||||
},
|
||||
"xai/grok-4.20-multi-agent-beta-0309": {
|
||||
"cache_read_input_token_cost": 2e-07,
|
||||
"input_cost_per_token": 2e-06,
|
||||
"litellm_provider": "xai",
|
||||
"max_input_tokens": 2000000,
|
||||
"max_output_tokens": 2000000,
|
||||
"max_tokens": 2000000,
|
||||
"mode": "chat",
|
||||
"output_cost_per_token": 6e-06,
|
||||
"source": "https://docs.x.ai/docs/models",
|
||||
"supports_function_calling": true,
|
||||
"supports_reasoning": true,
|
||||
"supports_tool_choice": true,
|
||||
"supports_vision": true,
|
||||
"supports_web_search": true
|
||||
},
|
||||
"xai/grok-4.20-beta-0309-reasoning": {
|
||||
"cache_read_input_token_cost": 2e-07,
|
||||
"input_cost_per_token": 2e-06,
|
||||
"litellm_provider": "xai",
|
||||
"max_input_tokens": 2000000,
|
||||
"max_output_tokens": 2000000,
|
||||
"max_tokens": 2000000,
|
||||
"mode": "chat",
|
||||
"output_cost_per_token": 6e-06,
|
||||
"source": "https://docs.x.ai/docs/models",
|
||||
"supports_function_calling": true,
|
||||
"supports_reasoning": true,
|
||||
"supports_tool_choice": true,
|
||||
"supports_vision": true,
|
||||
"supports_web_search": true
|
||||
},
|
||||
"xai/grok-4.20-beta-0309-non-reasoning": {
|
||||
"cache_read_input_token_cost": 2e-07,
|
||||
"input_cost_per_token": 2e-06,
|
||||
"litellm_provider": "xai",
|
||||
"max_input_tokens": 2000000,
|
||||
"max_output_tokens": 2000000,
|
||||
"max_tokens": 2000000,
|
||||
"mode": "chat",
|
||||
"output_cost_per_token": 6e-06,
|
||||
"source": "https://docs.x.ai/docs/models",
|
||||
"supports_function_calling": true,
|
||||
"supports_tool_choice": true,
|
||||
"supports_vision": true,
|
||||
"supports_web_search": true
|
||||
},
|
||||
"xai/grok-beta": {
|
||||
"input_cost_per_token": 5e-06,
|
||||
"litellm_provider": "xai",
|
||||
|
||||
Generated
+56
-32
@@ -1,4 +1,4 @@
|
||||
# This file is automatically @generated by Poetry 2.2.1 and should not be changed by hand.
|
||||
# This file is automatically @generated by Poetry 2.3.2 and should not be changed by hand.
|
||||
|
||||
[[package]]
|
||||
name = "a2a-sdk"
|
||||
@@ -7,11 +7,11 @@ description = "A2A Python SDK"
|
||||
optional = false
|
||||
python-versions = ">=3.10"
|
||||
groups = ["main", "proxy-dev"]
|
||||
markers = "python_version >= \"3.10\""
|
||||
files = [
|
||||
{file = "a2a_sdk-0.3.22-py3-none-any.whl", hash = "sha256:b98701135bb90b0ff85d35f31533b6b7a299bf810658c1c65f3814a6c15ea385"},
|
||||
{file = "a2a_sdk-0.3.22.tar.gz", hash = "sha256:77a5694bfc4f26679c11b70c7f1062522206d430b34bc1215cfbb1eba67b7e7d"},
|
||||
]
|
||||
markers = {main = "python_version >= \"3.10\" and extra == \"extra-proxy\"", proxy-dev = "python_version >= \"3.10\""}
|
||||
|
||||
[package.dependencies]
|
||||
google-api-core = ">=1.26.0"
|
||||
@@ -385,6 +385,7 @@ files = [
|
||||
{file = "azure_core-1.36.0-py3-none-any.whl", hash = "sha256:fee9923a3a753e94a259563429f3644aaf05c486d45b1215d098115102d91d3b"},
|
||||
{file = "azure_core-1.36.0.tar.gz", hash = "sha256:22e5605e6d0bf1d229726af56d9e92bc37b6e726b141a18be0b4d424131741b7"},
|
||||
]
|
||||
markers = {main = "extra == \"proxy\" or extra == \"extra-proxy\""}
|
||||
|
||||
[package.dependencies]
|
||||
requests = ">=2.21.0"
|
||||
@@ -405,6 +406,7 @@ files = [
|
||||
{file = "azure_identity-1.25.1-py3-none-any.whl", hash = "sha256:e9edd720af03dff020223cd269fa3a61e8f345ea75443858273bcb44844ab651"},
|
||||
{file = "azure_identity-1.25.1.tar.gz", hash = "sha256:87ca8328883de6036443e1c37b40e8dc8fb74898240f61071e09d2e369361456"},
|
||||
]
|
||||
markers = {main = "extra == \"proxy\" or extra == \"extra-proxy\""}
|
||||
|
||||
[package.dependencies]
|
||||
azure-core = ">=1.31.0"
|
||||
@@ -598,7 +600,7 @@ files = [
|
||||
{file = "cachetools-6.2.2-py3-none-any.whl", hash = "sha256:6c09c98183bf58560c97b2abfcedcbaf6a896a490f534b031b661d3723b45ace"},
|
||||
{file = "cachetools-6.2.2.tar.gz", hash = "sha256:8e6d266b25e539df852251cfd6f990b4bc3a141db73b939058d809ebd2590fc6"},
|
||||
]
|
||||
markers = {main = "extra == \"google\" or extra == \"extra-proxy\" or python_version >= \"3.10\"", proxy-dev = "python_version >= \"3.10\""}
|
||||
markers = {main = "python_version >= \"3.10\" and (extra == \"extra-proxy\" or extra == \"google\" or extra == \"mlflow\") or extra == \"google\" or extra == \"extra-proxy\"", proxy-dev = "python_version >= \"3.10\""}
|
||||
|
||||
[[package]]
|
||||
name = "certifi"
|
||||
@@ -705,7 +707,7 @@ files = [
|
||||
{file = "cffi-2.0.0-cp39-cp39-win_amd64.whl", hash = "sha256:b882b3df248017dba09d6b16defe9b5c407fe32fc7c65a9c69798e6175601be9"},
|
||||
{file = "cffi-2.0.0.tar.gz", hash = "sha256:44d1b5909021139fe36001ae048dbdde8214afa20200eda0f64c068cac5d5529"},
|
||||
]
|
||||
markers = {main = "platform_python_implementation != \"PyPy\" or extra == \"proxy\"", dev = "platform_python_implementation != \"PyPy\"", proxy-dev = "platform_python_implementation != \"PyPy\""}
|
||||
markers = {main = "(platform_python_implementation != \"PyPy\" or extra == \"proxy\") and (python_version >= \"3.10\" or extra == \"proxy\" or extra == \"extra-proxy\") and (extra == \"proxy\" or extra == \"extra-proxy\" or extra == \"mlflow\")", dev = "platform_python_implementation != \"PyPy\"", proxy-dev = "platform_python_implementation != \"PyPy\""}
|
||||
|
||||
[package.dependencies]
|
||||
pycparser = {version = "*", markers = "implementation_name != \"PyPy\""}
|
||||
@@ -1055,6 +1057,7 @@ files = [
|
||||
{file = "cryptography-43.0.3-pp39-pypy39_pp73-win_amd64.whl", hash = "sha256:2ce6fae5bdad59577b44e4dfed356944fbf1d925269114c28be377692643b4ff"},
|
||||
{file = "cryptography-43.0.3.tar.gz", hash = "sha256:315b9001266a492a6ff443b61238f956b214dbec9910a081ba5b6646a055a805"},
|
||||
]
|
||||
markers = {main = "python_version >= \"3.10\" and (extra == \"proxy\" or extra == \"extra-proxy\" or extra == \"mlflow\") or extra == \"proxy\" or extra == \"extra-proxy\""}
|
||||
|
||||
[package.dependencies]
|
||||
cffi = {version = ">=1.12", markers = "platform_python_implementation != \"PyPy\""}
|
||||
@@ -1837,11 +1840,11 @@ description = "Google API client core library"
|
||||
optional = false
|
||||
python-versions = ">=3.7"
|
||||
groups = ["main", "proxy-dev"]
|
||||
markers = "python_version >= \"3.14\""
|
||||
files = [
|
||||
{file = "google_api_core-2.25.2-py3-none-any.whl", hash = "sha256:e9a8f62d363dc8424a8497f4c2a47d6bcda6c16514c935629c257ab5d10210e7"},
|
||||
{file = "google_api_core-2.25.2.tar.gz", hash = "sha256:1c63aa6af0d0d5e37966f157a77f9396d820fba59f9e43e9415bc3dc5baff300"},
|
||||
]
|
||||
markers = {main = "python_version >= \"3.14\" and (extra == \"extra-proxy\" or extra == \"google\")", proxy-dev = "python_version >= \"3.14\""}
|
||||
|
||||
[package.dependencies]
|
||||
google-auth = ">=2.14.1,<3.0.0"
|
||||
@@ -1869,7 +1872,7 @@ files = [
|
||||
{file = "google_api_core-2.28.1-py3-none-any.whl", hash = "sha256:4021b0f8ceb77a6fb4de6fde4502cecab45062e66ff4f2895169e0b35bc9466c"},
|
||||
{file = "google_api_core-2.28.1.tar.gz", hash = "sha256:2b405df02d68e68ce0fbc138559e6036559e685159d148ae5861013dc201baf8"},
|
||||
]
|
||||
markers = {main = "(python_version >= \"3.10\" or extra == \"google\" or extra == \"extra-proxy\") and python_version < \"3.14\"", proxy-dev = "python_version >= \"3.10\" and python_version < \"3.14\""}
|
||||
markers = {main = "python_version < \"3.14\" and (extra == \"extra-proxy\" or extra == \"google\")", proxy-dev = "python_version >= \"3.10\" and python_version < \"3.14\""}
|
||||
|
||||
[package.dependencies]
|
||||
google-auth = ">=2.14.1,<3.0.0"
|
||||
@@ -1906,7 +1909,7 @@ files = [
|
||||
{file = "google_auth-2.43.0-py2.py3-none-any.whl", hash = "sha256:af628ba6fa493f75c7e9dbe9373d148ca9f4399b5ea29976519e0a3848eddd16"},
|
||||
{file = "google_auth-2.43.0.tar.gz", hash = "sha256:88228eee5fc21b62a1b5fe773ca15e67778cb07dc8363adcb4a8827b52d81483"},
|
||||
]
|
||||
markers = {main = "extra == \"google\" or extra == \"extra-proxy\" or python_version >= \"3.10\"", proxy-dev = "python_version >= \"3.10\""}
|
||||
markers = {main = "python_version >= \"3.10\" and (extra == \"extra-proxy\" or extra == \"google\" or extra == \"mlflow\") or extra == \"google\" or extra == \"extra-proxy\"", proxy-dev = "python_version >= \"3.10\""}
|
||||
|
||||
[package.dependencies]
|
||||
cachetools = ">=2.0.0,<7.0"
|
||||
@@ -2078,11 +2081,11 @@ files = [
|
||||
]
|
||||
|
||||
[package.dependencies]
|
||||
google-api-core = {version = ">=1.34.1,<2.0.dev0 || >=2.11.dev0,<3.0.0dev", extras = ["grpc"]}
|
||||
google-auth = ">=2.14.1,<2.24.0 || >2.24.0,<2.25.0 || >2.25.0,<3.0.0dev"
|
||||
grpc-google-iam-v1 = ">=0.12.4,<1.0.0dev"
|
||||
proto-plus = ">=1.22.3,<2.0.0dev"
|
||||
protobuf = ">=3.20.2,<4.21.0 || >4.21.0,<4.21.1 || >4.21.1,<4.21.2 || >4.21.2,<4.21.3 || >4.21.3,<4.21.4 || >4.21.4,<4.21.5 || >4.21.5,<6.0.0dev"
|
||||
google-api-core = {version = ">=1.34.1,<2.0.dev0 || >=2.11.dev0,<3.0.0.dev0", extras = ["grpc"]}
|
||||
google-auth = ">=2.14.1,<2.24.0 || >2.24.0,<2.25.0 || >2.25.0,<3.0.0.dev0"
|
||||
grpc-google-iam-v1 = ">=0.12.4,<1.0.0.dev0"
|
||||
proto-plus = ">=1.22.3,<2.0.0.dev0"
|
||||
protobuf = ">=3.20.2,<4.21.0 || >4.21.0,<4.21.1 || >4.21.1,<4.21.2 || >4.21.2,<4.21.3 || >4.21.3,<4.21.4 || >4.21.4,<4.21.5 || >4.21.5,<6.0.0.dev0"
|
||||
|
||||
[[package]]
|
||||
name = "google-cloud-resource-manager"
|
||||
@@ -2264,7 +2267,7 @@ files = [
|
||||
{file = "googleapis_common_protos-1.72.0-py3-none-any.whl", hash = "sha256:4299c5a82d5ae1a9702ada957347726b167f9f8d1fc352477702a1e851ff4038"},
|
||||
{file = "googleapis_common_protos-1.72.0.tar.gz", hash = "sha256:e55a601c1b32b52d7a3e65f43563e2aa61bcd737998ee672ac9b951cd49319f5"},
|
||||
]
|
||||
markers = {main = "extra == \"google\" or extra == \"extra-proxy\" or python_version >= \"3.10\""}
|
||||
markers = {main = "python_version >= \"3.10\" and (extra == \"extra-proxy\" or extra == \"google\") or extra == \"google\" or extra == \"extra-proxy\""}
|
||||
|
||||
[package.dependencies]
|
||||
grpcio = {version = ">=1.44.0,<2.0.0", optional = true, markers = "extra == \"grpc\""}
|
||||
@@ -2673,11 +2676,11 @@ description = "Consume Server-Sent Event (SSE) messages with HTTPX."
|
||||
optional = false
|
||||
python-versions = ">=3.9"
|
||||
groups = ["main", "proxy-dev"]
|
||||
markers = "python_version >= \"3.10\""
|
||||
files = [
|
||||
{file = "httpx_sse-0.4.3-py3-none-any.whl", hash = "sha256:0ac1c9fe3c0afad2e0ebb25a934a59f4c7823b60792691f779fad2c5568830fc"},
|
||||
{file = "httpx_sse-0.4.3.tar.gz", hash = "sha256:9b1ed0127459a66014aec3c56bebd93da3c1bc8bb6618c8082039a44889a755d"},
|
||||
]
|
||||
markers = {main = "python_version >= \"3.10\" and (extra == \"proxy\" or extra == \"extra-proxy\")", proxy-dev = "python_version >= \"3.10\""}
|
||||
|
||||
[[package]]
|
||||
name = "huey"
|
||||
@@ -3042,7 +3045,7 @@ files = [
|
||||
|
||||
[package.dependencies]
|
||||
attrs = ">=22.2.0"
|
||||
jsonschema-specifications = ">=2023.03.6"
|
||||
jsonschema-specifications = ">=2023.3.6"
|
||||
referencing = ">=0.28.4"
|
||||
rpds-py = ">=0.7.1"
|
||||
|
||||
@@ -3219,15 +3222,15 @@ files = [
|
||||
|
||||
[[package]]
|
||||
name = "litellm-proxy-extras"
|
||||
version = "0.4.56"
|
||||
version = "0.4.58"
|
||||
description = "Additional files for the LiteLLM Proxy. Reduces the size of the main litellm package."
|
||||
optional = true
|
||||
python-versions = "!=2.7.*,!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,!=3.6.*,!=3.7.*,>=3.8"
|
||||
groups = ["main"]
|
||||
markers = "extra == \"proxy\""
|
||||
files = [
|
||||
{file = "litellm_proxy_extras-0.4.56-py3-none-any.whl", hash = "sha256:52dbe3b5358c790e77e12f1ec5ef8e7508b383c2aaf41299750b6fb400908ee7"},
|
||||
{file = "litellm_proxy_extras-0.4.56.tar.gz", hash = "sha256:63ad59baa0defccc5c929cfd933ee7e32a6614b0fc5fa0fc45a12d7608e33f08"},
|
||||
{file = "litellm_proxy_extras-0.4.58-py3-none-any.whl", hash = "sha256:8863e70de833c0e35119a1cbbf583619bdebe52222efd5654586519175ba403b"},
|
||||
{file = "litellm_proxy_extras-0.4.58.tar.gz", hash = "sha256:84a67483329eced8be4fc61c4e43f117287aa4e3deeb8ddf8fe8cdc9a8508836"},
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -3713,6 +3716,7 @@ files = [
|
||||
{file = "msal-1.34.0-py3-none-any.whl", hash = "sha256:f669b1644e4950115da7a176441b0e13ec2975c29528d8b9e81316023676d6e1"},
|
||||
{file = "msal-1.34.0.tar.gz", hash = "sha256:76ba83b716ea5a6d75b0279c0ac353a0e05b820ca1f6682c0eb7f45190c43c2f"},
|
||||
]
|
||||
markers = {main = "extra == \"proxy\" or extra == \"extra-proxy\""}
|
||||
|
||||
[package.dependencies]
|
||||
cryptography = ">=2.5,<49"
|
||||
@@ -3733,6 +3737,7 @@ files = [
|
||||
{file = "msal_extensions-1.3.1-py3-none-any.whl", hash = "sha256:96d3de4d034504e969ac5e85bae8106c8373b5c6568e4c8fa7af2eca9dbe6bca"},
|
||||
{file = "msal_extensions-1.3.1.tar.gz", hash = "sha256:c5b0fd10f65ef62b5f1d62f4251d51cbcaf003fcedae8c91b040a488614be1a4"},
|
||||
]
|
||||
markers = {main = "extra == \"proxy\" or extra == \"extra-proxy\""}
|
||||
|
||||
[package.dependencies]
|
||||
msal = ">=1.29,<2"
|
||||
@@ -3983,6 +3988,7 @@ files = [
|
||||
{file = "nodeenv-1.9.1-py2.py3-none-any.whl", hash = "sha256:ba11c9782d29c27c70ffbdda2d7415098754709be8a7056d79a737cd901155c9"},
|
||||
{file = "nodeenv-1.9.1.tar.gz", hash = "sha256:6ec12890a2dab7946721edbfbcd91f3319c6ccc9aec47be7c7e6b7011ee6645f"},
|
||||
]
|
||||
markers = {main = "extra == \"extra-proxy\""}
|
||||
|
||||
[[package]]
|
||||
name = "numpy"
|
||||
@@ -4105,7 +4111,7 @@ files = [
|
||||
{file = "opentelemetry_api-1.39.1-py3-none-any.whl", hash = "sha256:2edd8463432a7f8443edce90972169b195e7d6a05500cd29e6d13898187c9950"},
|
||||
{file = "opentelemetry_api-1.39.1.tar.gz", hash = "sha256:fbde8c80e1b937a2c61f20347e91c0c18a1940cecf012d62e65a7caf08967c9c"},
|
||||
]
|
||||
markers = {main = "python_version >= \"3.10\""}
|
||||
markers = {main = "python_version >= \"3.10\" and extra == \"mlflow\""}
|
||||
|
||||
[package.dependencies]
|
||||
importlib-metadata = ">=6.0,<8.8.0"
|
||||
@@ -4220,7 +4226,7 @@ files = [
|
||||
{file = "opentelemetry_sdk-1.39.1-py3-none-any.whl", hash = "sha256:4d5482c478513ecb0a5d938dcc61394e647066e0cc2676bee9f3af3f3f45f01c"},
|
||||
{file = "opentelemetry_sdk-1.39.1.tar.gz", hash = "sha256:cf4d4563caf7bff906c9f7967e2be22d0d6b349b908be0d90fb21c8e9c995cc6"},
|
||||
]
|
||||
markers = {main = "python_version >= \"3.10\""}
|
||||
markers = {main = "python_version >= \"3.10\" and extra == \"mlflow\""}
|
||||
|
||||
[package.dependencies]
|
||||
opentelemetry-api = "1.39.1"
|
||||
@@ -4238,7 +4244,7 @@ files = [
|
||||
{file = "opentelemetry_semantic_conventions-0.60b1-py3-none-any.whl", hash = "sha256:9fa8c8b0c110da289809292b0591220d3a7b53c1526a23021e977d68597893fb"},
|
||||
{file = "opentelemetry_semantic_conventions-0.60b1.tar.gz", hash = "sha256:87c228b5a0669b748c76d76df6c364c369c28f1c465e50f661e39737e84bc953"},
|
||||
]
|
||||
markers = {main = "python_version >= \"3.10\""}
|
||||
markers = {main = "python_version >= \"3.10\" and extra == \"mlflow\""}
|
||||
|
||||
[package.dependencies]
|
||||
opentelemetry-api = "1.39.1"
|
||||
@@ -4455,6 +4461,21 @@ sql-other = ["SQLAlchemy (>=2.0.0)", "adbc-driver-postgresql (>=0.8.0)", "adbc-d
|
||||
test = ["hypothesis (>=6.46.1)", "pytest (>=7.3.2)", "pytest-xdist (>=2.2.0)"]
|
||||
xml = ["lxml (>=4.9.2)"]
|
||||
|
||||
[[package]]
|
||||
name = "parameterized"
|
||||
version = "0.9.0"
|
||||
description = "Parameterized testing with any Python test framework"
|
||||
optional = false
|
||||
python-versions = ">=3.7"
|
||||
groups = ["dev"]
|
||||
files = [
|
||||
{file = "parameterized-0.9.0-py2.py3-none-any.whl", hash = "sha256:4e0758e3d41bea3bbd05ec14fc2c24736723f243b28d702081aef438c9372b1b"},
|
||||
{file = "parameterized-0.9.0.tar.gz", hash = "sha256:7fc905272cefa4f364c1a3429cbbe9c0f98b793988efb5bf90aac80f08db09b1"},
|
||||
]
|
||||
|
||||
[package.extras]
|
||||
dev = ["jinja2"]
|
||||
|
||||
[[package]]
|
||||
name = "pathspec"
|
||||
version = "0.12.1"
|
||||
@@ -4722,6 +4743,7 @@ files = [
|
||||
{file = "prisma-0.11.0-py3-none-any.whl", hash = "sha256:22bb869e59a2968b99f3483bb417717273ffbc569fd1e9ceed95e5614cbaf53a"},
|
||||
{file = "prisma-0.11.0.tar.gz", hash = "sha256:3f2f2fd2361e1ec5ff655f2a04c7860c2f2a5bc4c91f78ca9c5c6349735bf693"},
|
||||
]
|
||||
markers = {main = "extra == \"extra-proxy\""}
|
||||
|
||||
[package.dependencies]
|
||||
click = ">=7.1.2"
|
||||
@@ -4895,7 +4917,7 @@ files = [
|
||||
{file = "proto_plus-1.26.1-py3-none-any.whl", hash = "sha256:13285478c2dcf2abb829db158e1047e2f1e8d63a077d94263c2b88b043c75a66"},
|
||||
{file = "proto_plus-1.26.1.tar.gz", hash = "sha256:21a515a4c4c0088a773899e23c7bbade3d18f9c66c73edd4c7ee3816bc96a012"},
|
||||
]
|
||||
markers = {main = "extra == \"google\" or extra == \"extra-proxy\" or python_version >= \"3.10\"", proxy-dev = "python_version >= \"3.10\""}
|
||||
markers = {main = "extra == \"google\" or extra == \"extra-proxy\"", proxy-dev = "python_version >= \"3.10\""}
|
||||
|
||||
[package.dependencies]
|
||||
protobuf = ">=3.19.0,<7.0.0"
|
||||
@@ -4923,7 +4945,7 @@ files = [
|
||||
{file = "protobuf-5.29.5-py3-none-any.whl", hash = "sha256:6cf42630262c59b2d8de33954443d94b746c952b01434fc58a417fdbd2e84bd5"},
|
||||
{file = "protobuf-5.29.5.tar.gz", hash = "sha256:bc1463bafd4b0929216c35f437a8e28731a2b7fe3d98bb77a600efced5a15c84"},
|
||||
]
|
||||
markers = {main = "extra == \"google\" or extra == \"extra-proxy\" or python_version >= \"3.10\""}
|
||||
markers = {main = "python_version >= \"3.10\" and (extra == \"extra-proxy\" or extra == \"google\" or extra == \"mlflow\") or extra == \"google\" or extra == \"extra-proxy\""}
|
||||
|
||||
[[package]]
|
||||
name = "psutil"
|
||||
@@ -5083,7 +5105,7 @@ files = [
|
||||
{file = "pyasn1-0.6.1-py3-none-any.whl", hash = "sha256:0d632f46f2ba09143da3a8afe9e33fb6f92fa2320ab7e886e2d0f7672af84629"},
|
||||
{file = "pyasn1-0.6.1.tar.gz", hash = "sha256:6f580d2bdd84365380830acf45550f2511469f673cb4a5ae3857a3170128b034"},
|
||||
]
|
||||
markers = {main = "extra == \"google\" or extra == \"extra-proxy\" or python_version >= \"3.10\"", proxy-dev = "python_version >= \"3.10\""}
|
||||
markers = {main = "python_version >= \"3.10\" and (extra == \"extra-proxy\" or extra == \"google\" or extra == \"mlflow\") or extra == \"google\" or extra == \"extra-proxy\"", proxy-dev = "python_version >= \"3.10\""}
|
||||
|
||||
[[package]]
|
||||
name = "pyasn1-modules"
|
||||
@@ -5096,7 +5118,7 @@ files = [
|
||||
{file = "pyasn1_modules-0.4.2-py3-none-any.whl", hash = "sha256:29253a9207ce32b64c3ac6600edc75368f98473906e8fd1043bd6b5b1de2c14a"},
|
||||
{file = "pyasn1_modules-0.4.2.tar.gz", hash = "sha256:677091de870a80aae844b1ca6134f54652fa2c8c5a52aa396440ac3106e941e6"},
|
||||
]
|
||||
markers = {main = "extra == \"google\" or extra == \"extra-proxy\" or python_version >= \"3.10\"", proxy-dev = "python_version >= \"3.10\""}
|
||||
markers = {main = "python_version >= \"3.10\" and (extra == \"extra-proxy\" or extra == \"google\" or extra == \"mlflow\") or extra == \"google\" or extra == \"extra-proxy\"", proxy-dev = "python_version >= \"3.10\""}
|
||||
|
||||
[package.dependencies]
|
||||
pyasn1 = ">=0.6.1,<0.7.0"
|
||||
@@ -5124,7 +5146,7 @@ files = [
|
||||
{file = "pycparser-2.23-py3-none-any.whl", hash = "sha256:e5c6e8d3fbad53479cab09ac03729e0a9faf2bee3db8208a550daf5af81a5934"},
|
||||
{file = "pycparser-2.23.tar.gz", hash = "sha256:78816d4f24add8f10a06d6f05b4d424ad9e96cfebf68a4ddc99c65c0720d00c2"},
|
||||
]
|
||||
markers = {main = "implementation_name != \"PyPy\" and (platform_python_implementation != \"PyPy\" or extra == \"proxy\")", dev = "platform_python_implementation != \"PyPy\" and implementation_name != \"PyPy\"", proxy-dev = "platform_python_implementation != \"PyPy\" and implementation_name != \"PyPy\""}
|
||||
markers = {main = "implementation_name != \"PyPy\" and (platform_python_implementation != \"PyPy\" or extra == \"proxy\") and (python_version >= \"3.10\" or extra == \"proxy\" or extra == \"extra-proxy\") and (extra == \"proxy\" or extra == \"extra-proxy\" or extra == \"mlflow\")", dev = "platform_python_implementation != \"PyPy\" and implementation_name != \"PyPy\"", proxy-dev = "platform_python_implementation != \"PyPy\" and implementation_name != \"PyPy\""}
|
||||
|
||||
[[package]]
|
||||
name = "pydantic"
|
||||
@@ -5347,6 +5369,7 @@ files = [
|
||||
{file = "pyjwt-2.12.1-py3-none-any.whl", hash = "sha256:28ca37c070cad8ba8cd9790cd940535d40274d22f80ab87f3ac6a713e6e8454c"},
|
||||
{file = "pyjwt-2.12.1.tar.gz", hash = "sha256:c74a7a2adf861c04d002db713dd85f84beb242228e671280bf709d765b03672b"},
|
||||
]
|
||||
markers = {main = "(python_version <= \"3.13\" or extra == \"proxy\" or extra == \"extra-proxy\") and (extra == \"extra-proxy\" or extra == \"proxy\")"}
|
||||
|
||||
[package.dependencies]
|
||||
cryptography = {version = ">=3.4.0", optional = true, markers = "extra == \"crypto\""}
|
||||
@@ -6290,7 +6313,7 @@ files = [
|
||||
{file = "rsa-4.9.1-py3-none-any.whl", hash = "sha256:68635866661c6836b8d39430f97a996acbd61bfa49406748ea243539fe239762"},
|
||||
{file = "rsa-4.9.1.tar.gz", hash = "sha256:e7bdbfdb5497da4c07dfd35530e1a902659db6ff241e39d9953cad06ebd0ae75"},
|
||||
]
|
||||
markers = {main = "extra == \"google\" or extra == \"extra-proxy\" or python_version >= \"3.10\"", proxy-dev = "python_version >= \"3.10\""}
|
||||
markers = {main = "python_version >= \"3.10\" and (extra == \"extra-proxy\" or extra == \"google\" or extra == \"mlflow\") or extra == \"google\" or extra == \"extra-proxy\"", proxy-dev = "python_version >= \"3.10\""}
|
||||
|
||||
[package.dependencies]
|
||||
pyasn1 = ">=0.1.3"
|
||||
@@ -6336,10 +6359,10 @@ files = [
|
||||
]
|
||||
|
||||
[package.dependencies]
|
||||
botocore = ">=1.37.4,<2.0a.0"
|
||||
botocore = ">=1.37.4,<2.0a0"
|
||||
|
||||
[package.extras]
|
||||
crt = ["botocore[crt] (>=1.37.4,<2.0a.0)"]
|
||||
crt = ["botocore[crt] (>=1.37.4,<2.0a0)"]
|
||||
|
||||
[[package]]
|
||||
name = "scikit-learn"
|
||||
@@ -6492,9 +6515,9 @@ tornado = ">=6.4.2,<7"
|
||||
urllib3 = ">=1.26,<3"
|
||||
|
||||
[package.extras]
|
||||
all = ["boto3 (>=1.34.98,<2)", "botocore (>=1.34.110,<2)", "cohere (>=5.9.4,<6.00)", "dagger-io (>=0.1.1) ; python_version >= \"3.11\"", "fastembed (>=0.3.0,<0.4) ; python_version < \"3.13\"", "google-cloud-aiplatform (>=1.45.0,<2)", "ipykernel (>=6.25.0,<7)", "llama-cpp-python (>=0.2.28,<0.2.86) ; python_version < \"3.13\"", "mistralai (>=0.0.12,<0.1.0)", "mypy (>=1.7.1,<2)", "ollama (>=0.1.7)", "pillow (>=10.2.0,<11.0.0) ; python_version < \"3.13\"", "pinecone[asyncio] (>=7.0.0,<8.0.0)", "psycopg[binary] (>=3.1.0,<4)", "pytest (>=8.2,<9.0)", "pytest-asyncio (>=0.24.0,<0.25)", "pytest-cov (>=4.1.0,<5)", "pytest-mock (>=3.12.0,<4)", "pytest-timeout", "pytest-xdist (>=3.5.0,<4)", "python-dotenv (>=1.0.0,<2)", "qdrant-client (>=1.11.1,<2)", "requests-mock (>=1.12.1,<2)", "ruff (>=0.11.2,<0.12)", "sentence-transformers (>=5.0.0) ; python_version < \"3.13\"", "tokenizers (>=0.19) ; python_version < \"3.13\"", "torch (>=2.6.0) ; python_version < \"3.13\"", "torchvision (>=0.17.0) ; python_version < \"3.13\"", "transformers (>=4.36.2) ; python_version < \"3.13\"", "types-pyyaml (>=6.0.12.12,<7)", "types-requests (>=2.31.0,<3)"]
|
||||
all = ["boto3 (>=1.34.98,<2)", "botocore (>=1.34.110,<2)", "cohere (>=5.9.4,<6.0)", "dagger-io (>=0.1.1) ; python_version >= \"3.11\"", "fastembed (>=0.3.0,<0.4) ; python_version < \"3.13\"", "google-cloud-aiplatform (>=1.45.0,<2)", "ipykernel (>=6.25.0,<7)", "llama-cpp-python (>=0.2.28,<0.2.86) ; python_version < \"3.13\"", "mistralai (>=0.0.12,<0.1.0)", "mypy (>=1.7.1,<2)", "ollama (>=0.1.7)", "pillow (>=10.2.0,<11.0.0) ; python_version < \"3.13\"", "pinecone[asyncio] (>=7.0.0,<8.0.0)", "psycopg[binary] (>=3.1.0,<4)", "pytest (>=8.2,<9.0)", "pytest-asyncio (>=0.24.0,<0.25)", "pytest-cov (>=4.1.0,<5)", "pytest-mock (>=3.12.0,<4)", "pytest-timeout", "pytest-xdist (>=3.5.0,<4)", "python-dotenv (>=1.0.0,<2)", "qdrant-client (>=1.11.1,<2)", "requests-mock (>=1.12.1,<2)", "ruff (>=0.11.2,<0.12)", "sentence-transformers (>=5.0.0) ; python_version < \"3.13\"", "tokenizers (>=0.19) ; python_version < \"3.13\"", "torch (>=2.6.0) ; python_version < \"3.13\"", "torchvision (>=0.17.0) ; python_version < \"3.13\"", "transformers (>=4.36.2) ; python_version < \"3.13\"", "types-pyyaml (>=6.0.12.12,<7)", "types-requests (>=2.31.0,<3)"]
|
||||
bedrock = ["boto3 (>=1.34.98,<2)", "botocore (>=1.34.110,<2)"]
|
||||
cohere = ["cohere (>=5.9.4,<6.00)"]
|
||||
cohere = ["cohere (>=5.9.4,<6.0)"]
|
||||
dev = ["dagger-io (>=0.1.1) ; python_version >= \"3.11\"", "ipykernel (>=6.25.0,<7)", "mypy (>=1.7.1,<2)", "pytest (>=8.2,<9.0)", "pytest-asyncio (>=0.24.0,<0.25)", "pytest-cov (>=4.1.0,<5)", "pytest-mock (>=3.12.0,<4)", "pytest-timeout", "pytest-xdist (>=3.5.0,<4)", "python-dotenv (>=1.0.0,<2)", "requests-mock (>=1.12.1,<2)", "ruff (>=0.11.2,<0.12)", "types-pyyaml (>=6.0.12.12,<7)", "types-requests (>=2.31.0,<3)"]
|
||||
docs = ["pydoc-markdown (>=4.8.2) ; python_version < \"3.12\""]
|
||||
fastembed = ["fastembed (>=0.3.0,<0.4) ; python_version < \"3.13\""]
|
||||
@@ -7222,6 +7245,7 @@ files = [
|
||||
{file = "tomlkit-0.13.3-py3-none-any.whl", hash = "sha256:c89c649d79ee40629a9fda55f8ace8c6a1b42deb912b2a8fd8d942ddadb606b0"},
|
||||
{file = "tomlkit-0.13.3.tar.gz", hash = "sha256:430cf247ee57df2b94ee3fbe588e71d362a941ebb545dec29b53961d61add2a1"},
|
||||
]
|
||||
markers = {main = "extra == \"extra-proxy\""}
|
||||
|
||||
[[package]]
|
||||
name = "tornado"
|
||||
@@ -7994,4 +8018,4 @@ utils = ["numpydoc"]
|
||||
[metadata]
|
||||
lock-version = "2.1"
|
||||
python-versions = ">=3.9,<4.0"
|
||||
content-hash = "1f3bbf967451633fb6290ba88980bdf4fbf83420024b14e862d1da717d903684"
|
||||
content-hash = "eda34dfd8b35474beffee18893d6782c7b3d0d3d2c610f66237eb97176f43527"
|
||||
|
||||
+2
-1
@@ -61,7 +61,7 @@ boto3 = { version = "^1.40.76", optional = true }
|
||||
redisvl = {version = "^0.4.1", optional = true, markers = "python_version >= '3.9' and python_version < '3.14'"}
|
||||
mcp = {version = ">=1.25.0,<2.0.0", optional = true, python = ">=3.10"}
|
||||
a2a-sdk = {version = "^0.3.22", optional = true, python = ">=3.10"}
|
||||
litellm-proxy-extras = {version = "^0.4.57", optional = true}
|
||||
litellm-proxy-extras = {version = "^0.4.58", optional = true}
|
||||
rich = {version = "^13.7.1", optional = true}
|
||||
litellm-enterprise = {version = "^0.1.33", optional = true}
|
||||
diskcache = {version = "^5.6.1", optional = true}
|
||||
@@ -167,6 +167,7 @@ langfuse = "^2.45.0"
|
||||
fastapi-offline = "^1.7.3"
|
||||
fakeredis = "^2.27.1"
|
||||
pytest-rerunfailures = "^14.0"
|
||||
parameterized = "^0.9.0"
|
||||
|
||||
[tool.poetry.group.proxy-dev.dependencies]
|
||||
prisma = "0.11.0"
|
||||
|
||||
+1
-1
@@ -57,7 +57,7 @@ grpcio>=1.75.0; python_version >= "3.14"
|
||||
sentry_sdk==2.21.0 # for sentry error handling
|
||||
detect-secrets==1.5.0 # Enterprise - secret detection / masking in LLM requests
|
||||
tzdata==2025.1 # IANA time zone database
|
||||
litellm-proxy-extras==0.4.57 # for proxy extras - e.g. prisma migrations
|
||||
litellm-proxy-extras==0.4.58 # for proxy extras - e.g. prisma migrations
|
||||
llm-sandbox==0.3.31 # for skill execution in sandbox
|
||||
### LITELLM PACKAGE DEPENDENCIES
|
||||
python-dotenv==1.0.1 # for env
|
||||
|
||||
@@ -146,6 +146,10 @@ model LiteLLM_TeamTable {
|
||||
litellm_model_table LiteLLM_ModelTable? @relation(fields: [model_id], references: [id])
|
||||
object_permission LiteLLM_ObjectPermissionTable? @relation(fields: [object_permission_id], references: [object_permission_id])
|
||||
projects LiteLLM_ProjectTable[]
|
||||
|
||||
@@index([organization_id])
|
||||
@@index([team_alias])
|
||||
@@index([created_at])
|
||||
}
|
||||
|
||||
// Projects sit between teams and keys for use-case management
|
||||
|
||||
Executable
+143
@@ -0,0 +1,143 @@
|
||||
#!/usr/bin/env bash
|
||||
# LiteLLM Installer
|
||||
# Usage: curl -fsSL https://raw.githubusercontent.com/BerriAI/litellm/main/scripts/install.sh | sh
|
||||
#
|
||||
# NOTE: set -e without pipefail for POSIX sh compatibility (dash on Ubuntu/Debian
|
||||
# ignores the shebang when invoked as `sh` and does not support `pipefail`).
|
||||
set -eu
|
||||
|
||||
MIN_PYTHON_MAJOR=3
|
||||
MIN_PYTHON_MINOR=9
|
||||
|
||||
# NOTE: before merging, this must stay as "litellm[proxy]" to install from PyPI.
|
||||
LITELLM_PACKAGE="litellm[proxy]"
|
||||
|
||||
# ── colours ────────────────────────────────────────────────────────────────
|
||||
if [ -t 1 ]; then
|
||||
BOLD='\033[1m'
|
||||
GREEN='\033[38;2;78;186;101m'
|
||||
GREY='\033[38;2;153;153;153m'
|
||||
RESET='\033[0m'
|
||||
else
|
||||
BOLD='' GREEN='' GREY='' RESET=''
|
||||
fi
|
||||
|
||||
info() { printf "${GREY} %s${RESET}\n" "$*"; }
|
||||
success() { printf "${GREEN} ✔ %s${RESET}\n" "$*"; }
|
||||
header() { printf "${BOLD} %s${RESET}\n" "$*"; }
|
||||
die() { printf "\n Error: %s\n\n" "$*" >&2; exit 1; }
|
||||
|
||||
# ── banner ─────────────────────────────────────────────────────────────────
|
||||
echo ""
|
||||
cat << 'EOF'
|
||||
██╗ ██╗████████╗███████╗██╗ ██╗ ███╗ ███╗
|
||||
██║ ██║╚══██╔══╝██╔════╝██║ ██║ ████╗ ████║
|
||||
██║ ██║ ██║ █████╗ ██║ ██║ ██╔████╔██║
|
||||
██║ ██║ ██║ ██╔══╝ ██║ ██║ ██║╚██╔╝██║
|
||||
███████╗██║ ██║ ███████╗███████╗███████╗██║ ╚═╝ ██║
|
||||
╚══════╝╚═╝ ╚═╝ ╚══════╝╚══════╝╚══════╝╚═╝ ╚═╝
|
||||
EOF
|
||||
printf " ${BOLD}LiteLLM Installer${RESET} ${GREY}— unified gateway for 100+ LLM providers${RESET}\n\n"
|
||||
|
||||
# ── OS detection ───────────────────────────────────────────────────────────
|
||||
OS="$(uname -s)"
|
||||
ARCH="$(uname -m)"
|
||||
|
||||
case "$OS" in
|
||||
Darwin) PLATFORM="macOS ($ARCH)" ;;
|
||||
Linux) PLATFORM="Linux ($ARCH)" ;;
|
||||
*) die "Unsupported OS: $OS. LiteLLM supports macOS and Linux." ;;
|
||||
esac
|
||||
|
||||
info "Platform: $PLATFORM"
|
||||
|
||||
# ── Python detection ───────────────────────────────────────────────────────
|
||||
PYTHON_BIN=""
|
||||
for candidate in python3 python; do
|
||||
if command -v "$candidate" >/dev/null 2>&1; then
|
||||
major="$("$candidate" -c 'import sys; print(sys.version_info.major)' 2>/dev/null || true)"
|
||||
minor="$("$candidate" -c 'import sys; print(sys.version_info.minor)' 2>/dev/null || true)"
|
||||
if [ "${major:-0}" -ge "$MIN_PYTHON_MAJOR" ] && [ "${minor:-0}" -ge "$MIN_PYTHON_MINOR" ]; then
|
||||
PYTHON_BIN="$(command -v "$candidate")"
|
||||
info "Python: $("$candidate" --version 2>&1)"
|
||||
break
|
||||
fi
|
||||
fi
|
||||
done
|
||||
|
||||
if [ -z "$PYTHON_BIN" ]; then
|
||||
die "Python ${MIN_PYTHON_MAJOR}.${MIN_PYTHON_MINOR}+ is required but not found.
|
||||
Install it from https://python.org/downloads or via your package manager:
|
||||
macOS: brew install python@3
|
||||
Ubuntu: sudo apt install python3 python3-pip"
|
||||
fi
|
||||
|
||||
# ── pip detection ──────────────────────────────────────────────────────────
|
||||
if ! "$PYTHON_BIN" -m pip --version >/dev/null 2>&1; then
|
||||
die "pip is not available. Install it with:
|
||||
$PYTHON_BIN -m ensurepip --upgrade"
|
||||
fi
|
||||
|
||||
# ── install ────────────────────────────────────────────────────────────────
|
||||
echo ""
|
||||
header "Installing litellm[proxy]…"
|
||||
echo ""
|
||||
|
||||
"$PYTHON_BIN" -m pip install --upgrade "${LITELLM_PACKAGE}" \
|
||||
|| die "pip install failed. Try manually: $PYTHON_BIN -m pip install '${LITELLM_PACKAGE}'"
|
||||
|
||||
# ── find the litellm binary installed by pip for this Python ───────────────
|
||||
# sysconfig.get_path('scripts') is where pip puts console scripts — reliable
|
||||
# even when the Python lives in a libexec/ symlink tree (e.g. Homebrew).
|
||||
SCRIPTS_DIR="$("$PYTHON_BIN" -c 'import sysconfig; print(sysconfig.get_path("scripts"))')"
|
||||
LITELLM_BIN="${SCRIPTS_DIR}/litellm"
|
||||
|
||||
if [ ! -x "$LITELLM_BIN" ]; then
|
||||
# Fall back to user-base bin (pip install --user)
|
||||
USER_BIN="$("$PYTHON_BIN" -c 'import site; print(site.getuserbase())')/bin"
|
||||
LITELLM_BIN="${USER_BIN}/litellm"
|
||||
fi
|
||||
|
||||
if [ ! -x "$LITELLM_BIN" ]; then
|
||||
die "litellm binary not found after install. Try: $PYTHON_BIN -m pip install --user '${LITELLM_PACKAGE}'"
|
||||
fi
|
||||
|
||||
# ── success banner ─────────────────────────────────────────────────────────
|
||||
echo ""
|
||||
success "LiteLLM installed"
|
||||
|
||||
installed_ver="$("$LITELLM_BIN" --version 2>&1 | grep -oE '[0-9]+\.[0-9]+\.[0-9]+' | head -1 || true)"
|
||||
[ -n "$installed_ver" ] && info "Version: $installed_ver"
|
||||
|
||||
# ── PATH hint ──────────────────────────────────────────────────────────────
|
||||
if ! command -v litellm >/dev/null 2>&1; then
|
||||
info "Note: add litellm to your PATH: export PATH=\"\$PATH:${SCRIPTS_DIR}\""
|
||||
fi
|
||||
|
||||
# ── launch setup wizard ────────────────────────────────────────────────────
|
||||
echo ""
|
||||
printf " ${BOLD}Run the interactive setup wizard?${RESET} ${GREY}(Y/n)${RESET}: "
|
||||
# /dev/tty may be unavailable in Docker/CI — default to yes if it can't be read
|
||||
answer=""
|
||||
if [ -r /dev/tty ]; then
|
||||
read -r answer </dev/tty || answer=""
|
||||
fi
|
||||
|
||||
if [ -z "$answer" ] || [ "$answer" = "y" ] || [ "$answer" = "Y" ]; then
|
||||
echo ""
|
||||
# Use /dev/tty for interactive input when available (stdin is a pipe from curl)
|
||||
if [ -r /dev/tty ]; then
|
||||
exec "$LITELLM_BIN" --setup </dev/tty
|
||||
else
|
||||
exec "$LITELLM_BIN" --setup
|
||||
fi
|
||||
else
|
||||
echo ""
|
||||
header "Quick start:"
|
||||
echo ""
|
||||
info " litellm --setup # interactive wizard"
|
||||
info " litellm --model gpt-4o # single-model quickstart"
|
||||
echo ""
|
||||
info "Docs: https://docs.litellm.ai"
|
||||
echo ""
|
||||
fi
|
||||
@@ -907,8 +907,9 @@ def test_router_region_pre_call_check(allowed_model_region):
|
||||
{
|
||||
"model_name": "gpt-3.5-turbo-large", # openai model name
|
||||
"litellm_params": { # params for litellm completion/embedding call
|
||||
"model": "gpt-3.5-turbo-1106",
|
||||
"model": "gpt-4.1-mini",
|
||||
"api_key": os.getenv("OPENAI_API_KEY"),
|
||||
"mock_response": "This is a mock response.",
|
||||
},
|
||||
"model_info": {"id": "2"},
|
||||
},
|
||||
|
||||
@@ -60,10 +60,33 @@ def assert_langfuse_request_matches_expected(
|
||||
)
|
||||
]
|
||||
|
||||
# When aggregating from multiple flush cycles, deduplicate by keeping
|
||||
# only one trace-create and one generation-create per trace_id.
|
||||
seen_types: dict = {}
|
||||
deduped_batch: list = []
|
||||
for item in actual_request_body["batch"]:
|
||||
item_type = item["type"]
|
||||
if item_type not in seen_types:
|
||||
seen_types[item_type] = True
|
||||
deduped_batch.append(item)
|
||||
actual_request_body["batch"] = deduped_batch
|
||||
|
||||
# Ensure canonical order: trace-create first, generation-create second
|
||||
actual_request_body["batch"].sort(
|
||||
key=lambda x: 0 if x["type"] == "trace-create" else 1
|
||||
)
|
||||
|
||||
print(
|
||||
"actual_request_body after filtering", json.dumps(actual_request_body, indent=4)
|
||||
)
|
||||
|
||||
assert len(actual_request_body["batch"]) >= 2, (
|
||||
f"Expected at least 2 batch items (trace-create + generation-create) "
|
||||
f"after filtering by trace_id={trace_id}, "
|
||||
f"but got {len(actual_request_body['batch'])}. "
|
||||
f"Items: {json.dumps(actual_request_body['batch'], indent=2)}"
|
||||
)
|
||||
|
||||
# Replace dynamic values in actual request body
|
||||
for item in actual_request_body["batch"]:
|
||||
|
||||
@@ -150,19 +173,36 @@ class TestLangfuseLogging:
|
||||
"""Helper method to verify Langfuse API calls"""
|
||||
await asyncio.sleep(3)
|
||||
|
||||
# Verify the call
|
||||
# Verify at least one call was made
|
||||
assert mock_post.call_count >= 1
|
||||
url = mock_post.call_args[0][0]
|
||||
request_body = mock_post.call_args[1].get("content")
|
||||
|
||||
# Parse the JSON string into a dict for assertions
|
||||
actual_request_body = json.loads(request_body)
|
||||
# Aggregate batch items from ALL calls — the Langfuse SDK may split
|
||||
# trace-create and generation-create across separate HTTP flushes.
|
||||
langfuse_url = "https://us.cloud.langfuse.com/api/public/ingestion"
|
||||
all_batch_items: list = []
|
||||
metadata: Optional[dict] = None
|
||||
for call in mock_post.call_args_list:
|
||||
url = call[0][0]
|
||||
if url != langfuse_url:
|
||||
continue
|
||||
request_body = call[1].get("content")
|
||||
if request_body:
|
||||
body = json.loads(request_body)
|
||||
all_batch_items.extend(body.get("batch", []))
|
||||
if metadata is None:
|
||||
metadata = body.get("metadata")
|
||||
|
||||
print("\nMocked Request Details:")
|
||||
print(f"URL: {url}")
|
||||
assert len(all_batch_items) > 0, "No Langfuse ingestion calls found"
|
||||
assert metadata is not None, "No metadata found in Langfuse calls"
|
||||
|
||||
actual_request_body = {
|
||||
"batch": all_batch_items,
|
||||
"metadata": metadata,
|
||||
}
|
||||
|
||||
print("\nMocked Request Details (aggregated from all calls):")
|
||||
print(f"Request Body: {json.dumps(actual_request_body, indent=4)}")
|
||||
|
||||
assert url == "https://us.cloud.langfuse.com/api/public/ingestion"
|
||||
assert_langfuse_request_matches_expected(
|
||||
actual_request_body,
|
||||
expected_file_name,
|
||||
@@ -170,6 +210,7 @@ class TestLangfuseLogging:
|
||||
)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.flaky(retries=3, delay=1)
|
||||
async def test_langfuse_logging_completion(self, mock_setup):
|
||||
"""Test Langfuse logging for chat completion"""
|
||||
setup = mock_setup
|
||||
@@ -185,6 +226,7 @@ class TestLangfuseLogging:
|
||||
)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.flaky(retries=3, delay=1)
|
||||
async def test_langfuse_logging_completion_with_tags(self, mock_setup):
|
||||
"""Test Langfuse logging for chat completion with tags"""
|
||||
setup = mock_setup
|
||||
@@ -203,6 +245,7 @@ class TestLangfuseLogging:
|
||||
)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.flaky(retries=3, delay=1)
|
||||
async def test_langfuse_logging_completion_with_tags_stream(self, mock_setup):
|
||||
"""Test Langfuse logging for chat completion with tags"""
|
||||
setup = mock_setup
|
||||
@@ -223,6 +266,7 @@ class TestLangfuseLogging:
|
||||
)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.flaky(retries=3, delay=1)
|
||||
async def test_langfuse_logging_completion_with_langfuse_metadata(self, mock_setup):
|
||||
"""Test Langfuse logging for chat completion with metadata for langfuse"""
|
||||
setup = mock_setup
|
||||
@@ -252,6 +296,7 @@ class TestLangfuseLogging:
|
||||
)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.flaky(retries=3, delay=1)
|
||||
async def test_langfuse_logging_with_non_serializable_metadata(self, mock_setup):
|
||||
"""Test Langfuse logging with metadata that requires preparation (Pydantic models, sets, etc)"""
|
||||
from pydantic import BaseModel
|
||||
@@ -358,6 +403,7 @@ class TestLangfuseLogging:
|
||||
)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.flaky(retries=3, delay=1)
|
||||
async def test_langfuse_logging_completion_with_malformed_llm_response(
|
||||
self, mock_setup
|
||||
):
|
||||
@@ -387,6 +433,7 @@ class TestLangfuseLogging:
|
||||
)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.flaky(retries=3, delay=1)
|
||||
async def test_langfuse_logging_completion_with_bedrock_llm_response(
|
||||
self, mock_setup
|
||||
):
|
||||
@@ -418,6 +465,7 @@ class TestLangfuseLogging:
|
||||
setup["mock_post"], "completion_with_bedrock_call.json", setup["trace_id"]
|
||||
)
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.flaky(retries=3, delay=1)
|
||||
async def test_langfuse_logging_completion_with_vertex_llm_response(
|
||||
self, mock_setup
|
||||
):
|
||||
@@ -449,6 +497,7 @@ class TestLangfuseLogging:
|
||||
)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.flaky(retries=3, delay=1)
|
||||
async def test_langfuse_logging_vllm_embedding(self, mock_setup):
|
||||
"""
|
||||
Test that the request sent to the vllm embedding endpoint is correct.
|
||||
@@ -500,6 +549,7 @@ class TestLangfuseLogging:
|
||||
)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.flaky(retries=3, delay=1)
|
||||
async def test_langfuse_logging_with_router(self, mock_setup):
|
||||
"""Test Langfuse logging with router"""
|
||||
litellm._turn_on_debug()
|
||||
|
||||
@@ -308,16 +308,19 @@ async def test_long_term_spend_accuracy_with_bursts():
|
||||
response = await chat_completion(session, key)
|
||||
print(f"Burst 2 - Request {i + 1}/{BURST_2_REQUESTS} completed")
|
||||
|
||||
# Poll until key spend reflects burst 2
|
||||
burst_1_spend = intermediate_key_info["info"]["spend"]
|
||||
# Poll until key spend reaches expected total (burst 1 + burst 2)
|
||||
start = time.time()
|
||||
while time.time() - start < 120:
|
||||
key_info_check = await get_spend_info(session, "key", key)
|
||||
current_spend = key_info_check["info"]["spend"]
|
||||
if current_spend > burst_1_spend:
|
||||
print(f"Key spend increased to {current_spend} after {time.time() - start:.1f}s")
|
||||
if abs(current_spend - expected_spend) < TOLERANCE:
|
||||
print(
|
||||
f"Total spend reached expected {expected_spend} after {time.time() - start:.1f}s"
|
||||
)
|
||||
break
|
||||
print(f"Key spend still {current_spend}, waiting for burst 2 flush...")
|
||||
print(
|
||||
f"Key spend {current_spend}, expected {expected_spend}, waiting..."
|
||||
)
|
||||
await asyncio.sleep(10)
|
||||
|
||||
# Allow extra time for all entity spend aggregations
|
||||
|
||||
@@ -3,7 +3,8 @@ import os
|
||||
import sys
|
||||
import time
|
||||
import unittest
|
||||
from datetime import datetime, timedelta
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from parameterized import parameterized
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
# Adds the grandparent directory to sys.path to allow importing project modules
|
||||
@@ -425,6 +426,7 @@ class TestOpenTelemetry(unittest.TestCase):
|
||||
) as mock_get_headers, patch.object(
|
||||
otel, "_get_tracer_with_dynamic_headers"
|
||||
) as mock_get_tracer:
|
||||
|
||||
# Test case 1: With dynamic headers
|
||||
mock_get_headers.return_value = {
|
||||
"arize-space-id": "test-space",
|
||||
@@ -2165,25 +2167,30 @@ class TestOpenTelemetrySemanticConventions138(unittest.TestCase):
|
||||
|
||||
mock_span.set_attribute.assert_any_call("gen_ai.operation.name", "chat")
|
||||
|
||||
def test_handle_failure_langfuse_otel_nulls_parent_span(self):
|
||||
@parameterized.expand([("_handle_success",), ("_handle_failure",)])
|
||||
def test_handle_success_failure_nulls_parent_span_if_ignore_context_propagation(
|
||||
self, handle_method: str
|
||||
):
|
||||
"""
|
||||
For langfuse_otel, _handle_failure should ignore parent spans from other providers
|
||||
and create a root-level error span (symmetric with _handle_success).
|
||||
If ignore_context_propagation is True, _handle_success should ignore any parent span
|
||||
and create a root-level span. This could be useful for langfuse_otel where
|
||||
_handle_success may ignore parent spans from other providers and create a root-level
|
||||
span (symmetric with _handle_failure).
|
||||
"""
|
||||
span_exporter = InMemorySpanExporter()
|
||||
tracer_provider = TracerProvider()
|
||||
tracer_provider.add_span_processor(SimpleSpanProcessor(span_exporter))
|
||||
|
||||
otel = OpenTelemetry(
|
||||
callback_name="langfuse_otel",
|
||||
config=OpenTelemetryConfig(ignore_context_propagation=True),
|
||||
tracer_provider=tracer_provider,
|
||||
)
|
||||
otel.tracer = tracer_provider.get_tracer("litellm")
|
||||
|
||||
other_tracer = tracer_provider.get_tracer("other_provider")
|
||||
other_span = other_tracer.start_span("other_provider_span")
|
||||
other_span = other_tracer.start_span("parent_span")
|
||||
|
||||
start = datetime.utcnow()
|
||||
start = datetime.now(timezone.utc)
|
||||
end = start + timedelta(seconds=1)
|
||||
|
||||
kwargs = {
|
||||
@@ -2202,23 +2209,33 @@ class TestOpenTelemetrySemanticConventions138(unittest.TestCase):
|
||||
"exception": Exception("test error"),
|
||||
}
|
||||
|
||||
otel._handle_failure(kwargs, None, start, end)
|
||||
with patch.dict(os.environ, {"USE_OTEL_LITELLM_REQUEST_SPAN": "true"}):
|
||||
if handle_method == "_handle_success":
|
||||
otel._handle_success(kwargs, None, start, end)
|
||||
elif handle_method == "_handle_failure":
|
||||
otel._handle_failure(kwargs, None, start, end)
|
||||
else:
|
||||
self.fail(f"Invalid handle_method: {handle_method}")
|
||||
|
||||
other_span.end()
|
||||
|
||||
spans = span_exporter.get_finished_spans()
|
||||
failure_spans = [s for s in spans if s.name != "other_provider_span"]
|
||||
child_spans = [s for s in spans if s.name != "parent_span"]
|
||||
child_span_ids = {s.context.span_id for s in child_spans if s.context}
|
||||
|
||||
self.assertTrue(failure_spans, "Expected at least one failure span")
|
||||
for span in failure_spans:
|
||||
self.assertIsNone(
|
||||
span.parent,
|
||||
f"langfuse_otel failure span should be a root span, but has parent: {span.parent}",
|
||||
)
|
||||
self.assertTrue(child_spans, "Expected at least one child span")
|
||||
for span in child_spans:
|
||||
assert (
|
||||
span.parent is None or span.parent.span_id in child_span_ids
|
||||
), f"if ignore_context_propagation is True, span should not have parent from other providers, but got parent: {span.parent}"
|
||||
|
||||
def test_handle_failure_non_langfuse_preserves_parent_span(self):
|
||||
@parameterized.expand([("_handle_success",), ("_handle_failure",)])
|
||||
def test_handle_success_failure_default_preserves_parent_span(
|
||||
self, handle_method: str
|
||||
):
|
||||
"""
|
||||
For non-langfuse_otel callbacks, _handle_failure should still use parent spans normally.
|
||||
For default otel callbacks, _handle_success should use parent spans normally.
|
||||
(symmetric with _handle_failure)
|
||||
"""
|
||||
span_exporter = InMemorySpanExporter()
|
||||
tracer_provider = TracerProvider()
|
||||
@@ -2229,7 +2246,7 @@ class TestOpenTelemetrySemanticConventions138(unittest.TestCase):
|
||||
|
||||
parent_span = otel.tracer.start_span("parent_span")
|
||||
|
||||
start = datetime.utcnow()
|
||||
start = datetime.now(timezone.utc)
|
||||
end = start + timedelta(seconds=1)
|
||||
|
||||
kwargs = {
|
||||
@@ -2249,19 +2266,81 @@ class TestOpenTelemetrySemanticConventions138(unittest.TestCase):
|
||||
}
|
||||
|
||||
with patch.dict(os.environ, {"USE_OTEL_LITELLM_REQUEST_SPAN": "true"}):
|
||||
otel._handle_failure(kwargs, None, start, end)
|
||||
if handle_method == "_handle_success":
|
||||
otel._handle_success(kwargs, None, start, end)
|
||||
elif handle_method == "_handle_failure":
|
||||
otel._handle_failure(kwargs, None, start, end)
|
||||
else:
|
||||
self.fail(f"Invalid handle_method: {handle_method}")
|
||||
|
||||
parent_span.end()
|
||||
|
||||
spans = span_exporter.get_finished_spans()
|
||||
child_spans = [s for s in spans if s.name != "parent_span"]
|
||||
|
||||
self.assertTrue(child_spans, "Expected at least one child failure span")
|
||||
self.assertTrue(child_spans, "Expected at least one child span")
|
||||
for span in child_spans:
|
||||
self.assertIsNotNone(
|
||||
span.parent,
|
||||
"Non-langfuse_otel failure span should have a parent",
|
||||
)
|
||||
assert (
|
||||
span.parent is not None
|
||||
), f"By default parent span should be preserved, but got None parent for span: {span.name}"
|
||||
|
||||
@parameterized.expand([("_handle_success",), ("_handle_failure",)])
|
||||
def test_handle_success_failure_with_context_propagation_preserves_parent_span(
|
||||
self, handle_method: str
|
||||
):
|
||||
"""
|
||||
For otel callbacks with context propagation enabled, _handle_success should
|
||||
use parent spans normally. (symmetric with _handle_failure)
|
||||
"""
|
||||
span_exporter = InMemorySpanExporter()
|
||||
tracer_provider = TracerProvider()
|
||||
tracer_provider.add_span_processor(SimpleSpanProcessor(span_exporter))
|
||||
|
||||
otel = OpenTelemetry(
|
||||
config=OpenTelemetryConfig(ignore_context_propagation=False),
|
||||
tracer_provider=tracer_provider,
|
||||
)
|
||||
otel.tracer = tracer_provider.get_tracer("litellm")
|
||||
|
||||
parent_span = otel.tracer.start_span("parent_span")
|
||||
|
||||
start = datetime.now(timezone.utc)
|
||||
end = start + timedelta(seconds=1)
|
||||
|
||||
kwargs = {
|
||||
"model": "gpt-4",
|
||||
"messages": [{"role": "user", "content": "Hello"}],
|
||||
"optional_params": {},
|
||||
"litellm_params": {
|
||||
"custom_llm_provider": "openai",
|
||||
"metadata": {"litellm_parent_otel_span": parent_span},
|
||||
},
|
||||
"standard_logging_object": {
|
||||
"id": "test-id",
|
||||
"call_type": "completion",
|
||||
"metadata": {},
|
||||
},
|
||||
"exception": Exception("test error"),
|
||||
}
|
||||
|
||||
with patch.dict(os.environ, {"USE_OTEL_LITELLM_REQUEST_SPAN": "true"}):
|
||||
if handle_method == "_handle_success":
|
||||
otel._handle_success(kwargs, None, start, end)
|
||||
elif handle_method == "_handle_failure":
|
||||
otel._handle_failure(kwargs, None, start, end)
|
||||
else:
|
||||
self.fail(f"Invalid handle_method: {handle_method}")
|
||||
|
||||
parent_span.end()
|
||||
|
||||
spans = span_exporter.get_finished_spans()
|
||||
child_spans = [s for s in spans if s.name != "parent_span"]
|
||||
|
||||
self.assertTrue(child_spans, "Expected at least one child span")
|
||||
for span in child_spans:
|
||||
assert (
|
||||
span.parent is not None
|
||||
), f"If ignore_context_propagation is False, parent span should be preserved, but got None parent for span: {span.name}"
|
||||
|
||||
def test_handle_failure_hasattr_guard_on_parent_name(self):
|
||||
"""
|
||||
@@ -2431,9 +2510,7 @@ class TestNoParentSpanDuplication(unittest.TestCase):
|
||||
otel._handle_success(kwargs, response_obj, start, end)
|
||||
|
||||
spans = span_exporter.get_finished_spans()
|
||||
proxy_spans = [
|
||||
s for s in spans if s.name == LITELLM_PROXY_REQUEST_SPAN_NAME
|
||||
]
|
||||
proxy_spans = [s for s in spans if s.name == LITELLM_PROXY_REQUEST_SPAN_NAME]
|
||||
self.assertEqual(len(proxy_spans), 1, "Should have exactly one proxy span")
|
||||
|
||||
proxy_attrs = proxy_spans[0].attributes or {}
|
||||
|
||||
@@ -77,6 +77,11 @@ class TestRequestCompliance:
|
||||
schema = spec_dict["components"]["schemas"]["CreateModelInteractionParams"]
|
||||
input_schema = schema["properties"]["input"]
|
||||
|
||||
# The input property may be inline oneOf or a $ref to InteractionsInput
|
||||
if "$ref" in input_schema:
|
||||
ref_name = input_schema["$ref"].split("/")[-1]
|
||||
input_schema = spec_dict["components"]["schemas"][ref_name]
|
||||
|
||||
# Should be oneOf with multiple types
|
||||
assert "oneOf" in input_schema
|
||||
|
||||
@@ -100,10 +105,21 @@ class TestRequestCompliance:
|
||||
assert "discriminator" in content_schema
|
||||
assert content_schema["discriminator"]["propertyName"] == "type"
|
||||
|
||||
# Check TextContent is an option
|
||||
mapping = content_schema["discriminator"]["mapping"]
|
||||
assert "text" in mapping
|
||||
print(f"Content type discriminator mapping: {list(mapping.keys())}")
|
||||
# Check TextContent is an option (via mapping if present, or via oneOf refs)
|
||||
mapping = content_schema["discriminator"].get("mapping")
|
||||
if mapping:
|
||||
assert "text" in mapping
|
||||
print(f"Content type discriminator mapping: {list(mapping.keys())}")
|
||||
else:
|
||||
# Discriminator without explicit mapping — verify via oneOf
|
||||
one_of = content_schema.get("oneOf", [])
|
||||
ref_names = [
|
||||
opt["$ref"].split("/")[-1] for opt in one_of if "$ref" in opt
|
||||
]
|
||||
assert "TextContent" in ref_names, (
|
||||
f"TextContent not found in oneOf refs: {ref_names}"
|
||||
)
|
||||
print(f"Content type discriminator (no mapping), oneOf refs: {ref_names}")
|
||||
|
||||
def test_text_content_schema(self, spec_dict):
|
||||
"""Verify TextContent schema."""
|
||||
|
||||
@@ -386,8 +386,56 @@ class TestXAICostCalculator:
|
||||
completion_tokens=50,
|
||||
total_tokens=150,
|
||||
)
|
||||
|
||||
|
||||
web_search_cost = cost_per_web_search_request(usage=usage, model_info={})
|
||||
|
||||
|
||||
# Expected cost: No web search data = $0.0
|
||||
assert web_search_cost == 0.0
|
||||
|
||||
def test_grok_4_20_beta_reasoning_cost_calculation(self):
|
||||
"""Test cost calculation for grok-4.20-beta-0309-reasoning model."""
|
||||
usage = Usage(prompt_tokens=100, completion_tokens=200, total_tokens=300)
|
||||
|
||||
prompt_cost, completion_cost = cost_per_token(
|
||||
model="grok-4.20-beta-0309-reasoning", usage=usage
|
||||
)
|
||||
|
||||
# Input: 100 tokens * $2e-6 = $0.0002
|
||||
# Output: 200 tokens * $6e-6 = $0.0012
|
||||
expected_prompt_cost = 100 * 2e-6
|
||||
expected_completion_cost = 200 * 6e-6
|
||||
|
||||
assert math.isclose(prompt_cost, expected_prompt_cost, rel_tol=1e-10)
|
||||
assert math.isclose(completion_cost, expected_completion_cost, rel_tol=1e-10)
|
||||
|
||||
def test_grok_4_20_beta_non_reasoning_cost_calculation(self):
|
||||
"""Test cost calculation for grok-4.20-beta-0309-non-reasoning model."""
|
||||
usage = Usage(prompt_tokens=50, completion_tokens=100, total_tokens=150)
|
||||
|
||||
prompt_cost, completion_cost = cost_per_token(
|
||||
model="grok-4.20-beta-0309-non-reasoning", usage=usage
|
||||
)
|
||||
|
||||
# Input: 50 tokens * $2e-6 = $0.0001
|
||||
# Output: 100 tokens * $6e-6 = $0.0006
|
||||
expected_prompt_cost = 50 * 2e-6
|
||||
expected_completion_cost = 100 * 6e-6
|
||||
|
||||
assert math.isclose(prompt_cost, expected_prompt_cost, rel_tol=1e-10)
|
||||
assert math.isclose(completion_cost, expected_completion_cost, rel_tol=1e-10)
|
||||
|
||||
def test_grok_4_20_multi_agent_cost_calculation(self):
|
||||
"""Test cost calculation for grok-4.20-multi-agent-beta-0309 model."""
|
||||
usage = Usage(prompt_tokens=200, completion_tokens=300, total_tokens=500)
|
||||
|
||||
prompt_cost, completion_cost = cost_per_token(
|
||||
model="grok-4.20-multi-agent-beta-0309", usage=usage
|
||||
)
|
||||
|
||||
# Input: 200 tokens * $2e-6 = $0.0004
|
||||
# Output: 300 tokens * $6e-6 = $0.0018
|
||||
expected_prompt_cost = 200 * 2e-6
|
||||
expected_completion_cost = 300 * 6e-6
|
||||
|
||||
assert math.isclose(prompt_cost, expected_prompt_cost, rel_tol=1e-10)
|
||||
assert math.isclose(completion_cost, expected_completion_cost, rel_tol=1e-10)
|
||||
|
||||
@@ -0,0 +1,707 @@
|
||||
"""
|
||||
Tests for pre_mcp_call guardrail hook header mutation support.
|
||||
|
||||
Validates that:
|
||||
1. _convert_mcp_hook_response_to_kwargs extracts extra_headers from hook response
|
||||
2. pre_call_tool_check returns hook-provided extra_headers AND modified arguments
|
||||
3. call_tool flows hook headers and modified arguments downstream
|
||||
4. Hook-provided headers take highest priority (merge after static_headers)
|
||||
5. OpenAPI-backed servers log a warning and continue (skip injection) when hook headers are present
|
||||
6. JWT claims are propagated in both standard and virtual-key fast paths
|
||||
7. Backward compatibility: hooks without extra_headers continue to work
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
from typing import Any, Dict, Optional
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
import pytest
|
||||
from fastapi import HTTPException
|
||||
|
||||
from litellm.proxy._experimental.mcp_server.mcp_server_manager import MCPServerManager
|
||||
from litellm.proxy._types import UserAPIKeyAuth
|
||||
from litellm.proxy.utils import ProxyLogging
|
||||
from litellm.types.mcp import MCPAuth, MCPTransport
|
||||
from litellm.types.mcp_server.mcp_server_manager import MCPServer
|
||||
|
||||
|
||||
class TestConvertMcpHookResponseToKwargs:
|
||||
"""Tests for ProxyLogging._convert_mcp_hook_response_to_kwargs"""
|
||||
|
||||
def setup_method(self):
|
||||
self.proxy_logging = ProxyLogging(user_api_key_cache=MagicMock())
|
||||
|
||||
def test_returns_original_kwargs_when_response_is_none(self):
|
||||
original = {"arguments": {"key": "val"}, "name": "tool"}
|
||||
result = self.proxy_logging._convert_mcp_hook_response_to_kwargs(
|
||||
None, original
|
||||
)
|
||||
assert result == original
|
||||
|
||||
def test_returns_original_kwargs_when_response_is_empty_dict(self):
|
||||
original = {"arguments": {"key": "val"}}
|
||||
result = self.proxy_logging._convert_mcp_hook_response_to_kwargs({}, original)
|
||||
assert result == original
|
||||
|
||||
def test_extracts_modified_arguments(self):
|
||||
original = {"arguments": {"old": "value"}}
|
||||
response = {"modified_arguments": {"new": "value"}}
|
||||
result = self.proxy_logging._convert_mcp_hook_response_to_kwargs(
|
||||
response, original
|
||||
)
|
||||
assert result["arguments"] == {"new": "value"}
|
||||
|
||||
def test_extracts_extra_headers(self):
|
||||
original = {"arguments": {"key": "val"}}
|
||||
response = {"extra_headers": {"Authorization": "Bearer signed-jwt"}}
|
||||
result = self.proxy_logging._convert_mcp_hook_response_to_kwargs(
|
||||
response, original
|
||||
)
|
||||
assert result["extra_headers"] == {"Authorization": "Bearer signed-jwt"}
|
||||
|
||||
def test_extracts_both_arguments_and_headers(self):
|
||||
original = {"arguments": {"old": "value"}}
|
||||
response = {
|
||||
"modified_arguments": {"new": "value"},
|
||||
"extra_headers": {"X-Custom": "header-val"},
|
||||
}
|
||||
result = self.proxy_logging._convert_mcp_hook_response_to_kwargs(
|
||||
response, original
|
||||
)
|
||||
assert result["arguments"] == {"new": "value"}
|
||||
assert result["extra_headers"] == {"X-Custom": "header-val"}
|
||||
|
||||
def test_no_extra_headers_key_preserves_original(self):
|
||||
"""Backward compat: hooks that only return modified_arguments still work."""
|
||||
original = {"arguments": {"key": "val"}}
|
||||
response = {"modified_arguments": {"key": "new_val"}}
|
||||
result = self.proxy_logging._convert_mcp_hook_response_to_kwargs(
|
||||
response, original
|
||||
)
|
||||
assert "extra_headers" not in result
|
||||
assert result["arguments"] == {"key": "new_val"}
|
||||
|
||||
def test_empty_extra_headers_not_set(self):
|
||||
"""Empty dict for extra_headers is falsy and should not be set."""
|
||||
original = {"arguments": {"key": "val"}}
|
||||
response = {"extra_headers": {}}
|
||||
result = self.proxy_logging._convert_mcp_hook_response_to_kwargs(
|
||||
response, original
|
||||
)
|
||||
assert "extra_headers" not in result
|
||||
|
||||
|
||||
class TestPreCallToolCheckReturnsHeaders:
|
||||
"""Tests that pre_call_tool_check returns hook-provided headers."""
|
||||
|
||||
def _make_server(self, name="test_server"):
|
||||
return MCPServer(
|
||||
server_id="test-id",
|
||||
name=name,
|
||||
server_name=name,
|
||||
url="https://example.com",
|
||||
transport=MCPTransport.http,
|
||||
auth_type=MCPAuth.none,
|
||||
)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_returns_empty_dict_when_hook_has_no_headers(self):
|
||||
manager = MCPServerManager()
|
||||
server = self._make_server()
|
||||
|
||||
proxy_logging = MagicMock(spec=ProxyLogging)
|
||||
proxy_logging._create_mcp_request_object_from_kwargs = MagicMock(
|
||||
return_value=MagicMock()
|
||||
)
|
||||
proxy_logging._convert_mcp_to_llm_format = MagicMock(
|
||||
return_value={"model": "fake"}
|
||||
)
|
||||
proxy_logging.pre_call_hook = AsyncMock(
|
||||
return_value={"modified_arguments": {"key": "val"}}
|
||||
)
|
||||
proxy_logging._convert_mcp_hook_response_to_kwargs = MagicMock(
|
||||
return_value={"arguments": {"key": "val"}}
|
||||
)
|
||||
|
||||
with patch.object(manager, "check_allowed_or_banned_tools", return_value=True):
|
||||
with patch.object(
|
||||
manager,
|
||||
"check_tool_permission_for_key_team",
|
||||
new_callable=AsyncMock,
|
||||
):
|
||||
with patch.object(manager, "validate_allowed_params"):
|
||||
result = await manager.pre_call_tool_check(
|
||||
name="test_tool",
|
||||
arguments={"key": "val"},
|
||||
server_name="test_server",
|
||||
user_api_key_auth=None,
|
||||
proxy_logging_obj=proxy_logging,
|
||||
server=server,
|
||||
)
|
||||
|
||||
assert result == {}
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_returns_extra_headers_from_hook(self):
|
||||
manager = MCPServerManager()
|
||||
server = self._make_server()
|
||||
|
||||
hook_headers = {"Authorization": "Bearer signed-jwt", "X-Trace-Id": "abc123"}
|
||||
|
||||
proxy_logging = MagicMock(spec=ProxyLogging)
|
||||
proxy_logging._create_mcp_request_object_from_kwargs = MagicMock(
|
||||
return_value=MagicMock()
|
||||
)
|
||||
proxy_logging._convert_mcp_to_llm_format = MagicMock(
|
||||
return_value={"model": "fake"}
|
||||
)
|
||||
proxy_logging.pre_call_hook = AsyncMock(
|
||||
return_value={"extra_headers": hook_headers}
|
||||
)
|
||||
proxy_logging._convert_mcp_hook_response_to_kwargs = MagicMock(
|
||||
return_value={"arguments": {"key": "val"}, "extra_headers": hook_headers}
|
||||
)
|
||||
|
||||
with patch.object(manager, "check_allowed_or_banned_tools", return_value=True):
|
||||
with patch.object(
|
||||
manager,
|
||||
"check_tool_permission_for_key_team",
|
||||
new_callable=AsyncMock,
|
||||
):
|
||||
with patch.object(manager, "validate_allowed_params"):
|
||||
result = await manager.pre_call_tool_check(
|
||||
name="test_tool",
|
||||
arguments={"key": "val"},
|
||||
server_name="test_server",
|
||||
user_api_key_auth=None,
|
||||
proxy_logging_obj=proxy_logging,
|
||||
server=server,
|
||||
)
|
||||
|
||||
assert result["extra_headers"] == hook_headers
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_returns_empty_dict_when_hook_returns_none(self):
|
||||
manager = MCPServerManager()
|
||||
server = self._make_server()
|
||||
|
||||
proxy_logging = MagicMock(spec=ProxyLogging)
|
||||
proxy_logging._create_mcp_request_object_from_kwargs = MagicMock(
|
||||
return_value=MagicMock()
|
||||
)
|
||||
proxy_logging._convert_mcp_to_llm_format = MagicMock(
|
||||
return_value={"model": "fake"}
|
||||
)
|
||||
proxy_logging.pre_call_hook = AsyncMock(return_value=None)
|
||||
|
||||
with patch.object(manager, "check_allowed_or_banned_tools", return_value=True):
|
||||
with patch.object(
|
||||
manager,
|
||||
"check_tool_permission_for_key_team",
|
||||
new_callable=AsyncMock,
|
||||
):
|
||||
with patch.object(manager, "validate_allowed_params"):
|
||||
result = await manager.pre_call_tool_check(
|
||||
name="test_tool",
|
||||
arguments={"key": "val"},
|
||||
server_name="test_server",
|
||||
user_api_key_auth=None,
|
||||
proxy_logging_obj=proxy_logging,
|
||||
server=server,
|
||||
)
|
||||
|
||||
assert result == {}
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_returns_modified_arguments_from_hook(self):
|
||||
"""Modified arguments from the hook must be returned so the caller can use them."""
|
||||
manager = MCPServerManager()
|
||||
server = self._make_server()
|
||||
|
||||
original_args = {"key": "original"}
|
||||
modified_args = {"key": "modified", "extra": "added"}
|
||||
|
||||
proxy_logging = MagicMock(spec=ProxyLogging)
|
||||
proxy_logging._create_mcp_request_object_from_kwargs = MagicMock(
|
||||
return_value=MagicMock()
|
||||
)
|
||||
proxy_logging._convert_mcp_to_llm_format = MagicMock(
|
||||
return_value={"model": "fake"}
|
||||
)
|
||||
proxy_logging.pre_call_hook = AsyncMock(
|
||||
return_value={"modified_arguments": modified_args}
|
||||
)
|
||||
proxy_logging._convert_mcp_hook_response_to_kwargs = MagicMock(
|
||||
return_value={"arguments": modified_args}
|
||||
)
|
||||
|
||||
with patch.object(manager, "check_allowed_or_banned_tools", return_value=True):
|
||||
with patch.object(
|
||||
manager,
|
||||
"check_tool_permission_for_key_team",
|
||||
new_callable=AsyncMock,
|
||||
):
|
||||
with patch.object(manager, "validate_allowed_params"):
|
||||
result = await manager.pre_call_tool_check(
|
||||
name="test_tool",
|
||||
arguments=original_args,
|
||||
server_name="test_server",
|
||||
user_api_key_auth=None,
|
||||
proxy_logging_obj=proxy_logging,
|
||||
server=server,
|
||||
)
|
||||
|
||||
assert result["arguments"] == modified_args
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_returns_both_modified_arguments_and_headers(self):
|
||||
"""Hook can modify both arguments and inject headers simultaneously."""
|
||||
manager = MCPServerManager()
|
||||
server = self._make_server()
|
||||
|
||||
modified_args = {"key": "modified"}
|
||||
hook_headers = {"Authorization": "Bearer jwt"}
|
||||
|
||||
proxy_logging = MagicMock(spec=ProxyLogging)
|
||||
proxy_logging._create_mcp_request_object_from_kwargs = MagicMock(
|
||||
return_value=MagicMock()
|
||||
)
|
||||
proxy_logging._convert_mcp_to_llm_format = MagicMock(
|
||||
return_value={"model": "fake"}
|
||||
)
|
||||
proxy_logging.pre_call_hook = AsyncMock(return_value={"dummy": True})
|
||||
proxy_logging._convert_mcp_hook_response_to_kwargs = MagicMock(
|
||||
return_value={"arguments": modified_args, "extra_headers": hook_headers}
|
||||
)
|
||||
|
||||
with patch.object(manager, "check_allowed_or_banned_tools", return_value=True):
|
||||
with patch.object(
|
||||
manager,
|
||||
"check_tool_permission_for_key_team",
|
||||
new_callable=AsyncMock,
|
||||
):
|
||||
with patch.object(manager, "validate_allowed_params"):
|
||||
result = await manager.pre_call_tool_check(
|
||||
name="test_tool",
|
||||
arguments={"key": "original"},
|
||||
server_name="test_server",
|
||||
user_api_key_auth=None,
|
||||
proxy_logging_obj=proxy_logging,
|
||||
server=server,
|
||||
)
|
||||
|
||||
assert result["arguments"] == modified_args
|
||||
assert result["extra_headers"] == hook_headers
|
||||
|
||||
|
||||
class TestCallToolFlowsHookHeaders:
|
||||
"""Tests that call_tool passes hook_extra_headers to _call_regular_mcp_tool."""
|
||||
|
||||
def _make_server(self, name="test_server"):
|
||||
return MCPServer(
|
||||
server_id="test-id",
|
||||
name=name,
|
||||
server_name=name,
|
||||
url="https://example.com",
|
||||
transport=MCPTransport.http,
|
||||
auth_type=MCPAuth.none,
|
||||
)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_hook_headers_passed_to_call_regular_mcp_tool(self):
|
||||
"""Verify that hook_extra_headers kwarg is forwarded."""
|
||||
manager = MCPServerManager()
|
||||
server = self._make_server()
|
||||
|
||||
hook_headers = {"Authorization": "Bearer signed-jwt"}
|
||||
|
||||
with patch.object(
|
||||
manager,
|
||||
"_get_mcp_server_from_tool_name",
|
||||
return_value=server,
|
||||
):
|
||||
with patch.object(
|
||||
manager,
|
||||
"pre_call_tool_check",
|
||||
new_callable=AsyncMock,
|
||||
return_value={"extra_headers": hook_headers},
|
||||
):
|
||||
with patch.object(
|
||||
manager,
|
||||
"_create_during_hook_task",
|
||||
return_value=asyncio.create_task(asyncio.sleep(0)),
|
||||
):
|
||||
with patch.object(
|
||||
manager,
|
||||
"_call_regular_mcp_tool",
|
||||
new_callable=AsyncMock,
|
||||
return_value=MagicMock(),
|
||||
) as mock_call:
|
||||
proxy_logging = MagicMock(spec=ProxyLogging)
|
||||
|
||||
await manager.call_tool(
|
||||
server_name="test_server",
|
||||
name="test_tool",
|
||||
arguments={"key": "val"},
|
||||
proxy_logging_obj=proxy_logging,
|
||||
)
|
||||
|
||||
mock_call.assert_called_once()
|
||||
call_kwargs = mock_call.call_args
|
||||
assert call_kwargs.kwargs.get("hook_extra_headers") == hook_headers
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_no_hook_headers_when_no_proxy_logging(self):
|
||||
"""Without proxy_logging_obj, no pre_call_tool_check runs."""
|
||||
manager = MCPServerManager()
|
||||
server = self._make_server()
|
||||
|
||||
with patch.object(
|
||||
manager,
|
||||
"_get_mcp_server_from_tool_name",
|
||||
return_value=server,
|
||||
):
|
||||
with patch.object(
|
||||
manager,
|
||||
"_call_regular_mcp_tool",
|
||||
new_callable=AsyncMock,
|
||||
return_value=MagicMock(),
|
||||
) as mock_call:
|
||||
await manager.call_tool(
|
||||
server_name="test_server",
|
||||
name="test_tool",
|
||||
arguments={"key": "val"},
|
||||
proxy_logging_obj=None,
|
||||
)
|
||||
|
||||
mock_call.assert_called_once()
|
||||
call_kwargs = mock_call.call_args
|
||||
assert call_kwargs.kwargs.get("hook_extra_headers") is None
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_modified_arguments_passed_to_downstream(self):
|
||||
"""Hook-modified arguments must be used for the actual tool call."""
|
||||
manager = MCPServerManager()
|
||||
server = self._make_server()
|
||||
|
||||
modified_args = {"key": "modified_by_hook"}
|
||||
|
||||
with patch.object(
|
||||
manager,
|
||||
"_get_mcp_server_from_tool_name",
|
||||
return_value=server,
|
||||
):
|
||||
with patch.object(
|
||||
manager,
|
||||
"pre_call_tool_check",
|
||||
new_callable=AsyncMock,
|
||||
return_value={"arguments": modified_args},
|
||||
):
|
||||
with patch.object(
|
||||
manager,
|
||||
"_create_during_hook_task",
|
||||
return_value=asyncio.create_task(asyncio.sleep(0)),
|
||||
):
|
||||
with patch.object(
|
||||
manager,
|
||||
"_call_regular_mcp_tool",
|
||||
new_callable=AsyncMock,
|
||||
return_value=MagicMock(),
|
||||
) as mock_call:
|
||||
proxy_logging = MagicMock(spec=ProxyLogging)
|
||||
|
||||
await manager.call_tool(
|
||||
server_name="test_server",
|
||||
name="test_tool",
|
||||
arguments={"key": "original"},
|
||||
proxy_logging_obj=proxy_logging,
|
||||
)
|
||||
|
||||
mock_call.assert_called_once()
|
||||
call_kwargs = mock_call.call_args
|
||||
assert call_kwargs.kwargs.get("arguments") == modified_args
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_openapi_server_warns_and_continues_on_hook_headers(self):
|
||||
"""OpenAPI-backed servers log a warning and continue when hook injects headers."""
|
||||
manager = MCPServerManager()
|
||||
server = MCPServer(
|
||||
server_id="test-id",
|
||||
name="openapi_server",
|
||||
server_name="openapi_server",
|
||||
url="https://example.com",
|
||||
transport=MCPTransport.http,
|
||||
auth_type=MCPAuth.none,
|
||||
spec_path="/path/to/spec.yaml",
|
||||
)
|
||||
|
||||
with patch.object(
|
||||
manager, "_get_mcp_server_from_tool_name", return_value=server
|
||||
):
|
||||
with patch.object(
|
||||
manager,
|
||||
"pre_call_tool_check",
|
||||
new_callable=AsyncMock,
|
||||
return_value={"extra_headers": {"Authorization": "Bearer jwt"}},
|
||||
):
|
||||
with patch.object(
|
||||
manager,
|
||||
"_create_during_hook_task",
|
||||
return_value=asyncio.create_task(asyncio.sleep(0)),
|
||||
):
|
||||
with patch.object(
|
||||
manager,
|
||||
"_call_openapi_tool_handler",
|
||||
new_callable=AsyncMock,
|
||||
return_value=MagicMock(),
|
||||
):
|
||||
import litellm.proxy._experimental.mcp_server.mcp_server_manager as mgr_mod
|
||||
|
||||
proxy_logging = MagicMock(spec=ProxyLogging)
|
||||
|
||||
with patch.object(mgr_mod, "verbose_logger") as mock_logger:
|
||||
# Should NOT raise — just warn and proceed
|
||||
await manager.call_tool(
|
||||
server_name="openapi_server",
|
||||
name="test_tool",
|
||||
arguments={},
|
||||
proxy_logging_obj=proxy_logging,
|
||||
)
|
||||
mock_logger.warning.assert_called_once()
|
||||
assert "header injection is not supported" in mock_logger.warning.call_args[0][0]
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_openapi_server_no_error_without_hook_headers(self):
|
||||
"""No exception when OpenAPI server has no hook-injected headers."""
|
||||
manager = MCPServerManager()
|
||||
server = MCPServer(
|
||||
server_id="test-id",
|
||||
name="openapi_server",
|
||||
server_name="openapi_server",
|
||||
url="https://example.com",
|
||||
transport=MCPTransport.http,
|
||||
auth_type=MCPAuth.none,
|
||||
spec_path="/path/to/spec.yaml",
|
||||
)
|
||||
|
||||
with patch.object(
|
||||
manager, "_get_mcp_server_from_tool_name", return_value=server
|
||||
):
|
||||
with patch.object(
|
||||
manager,
|
||||
"pre_call_tool_check",
|
||||
new_callable=AsyncMock,
|
||||
return_value={},
|
||||
):
|
||||
with patch.object(
|
||||
manager,
|
||||
"_create_during_hook_task",
|
||||
return_value=asyncio.create_task(asyncio.sleep(0)),
|
||||
):
|
||||
with patch.object(
|
||||
manager,
|
||||
"_call_openapi_tool_handler",
|
||||
new_callable=AsyncMock,
|
||||
return_value=MagicMock(),
|
||||
):
|
||||
proxy_logging = MagicMock(spec=ProxyLogging)
|
||||
|
||||
await manager.call_tool(
|
||||
server_name="openapi_server",
|
||||
name="test_tool",
|
||||
arguments={},
|
||||
proxy_logging_obj=proxy_logging,
|
||||
)
|
||||
|
||||
|
||||
class TestHookHeaderMergePriority:
|
||||
"""Tests that hook-provided headers have highest priority in _call_regular_mcp_tool."""
|
||||
|
||||
def _make_server(
|
||||
self,
|
||||
static_headers: Optional[Dict[str, str]] = None,
|
||||
extra_headers_config: Optional[list] = None,
|
||||
):
|
||||
return MCPServer(
|
||||
server_id="test-id",
|
||||
name="Test Server",
|
||||
server_name="test_server",
|
||||
url="https://example.com",
|
||||
transport=MCPTransport.http,
|
||||
auth_type=MCPAuth.none,
|
||||
static_headers=static_headers,
|
||||
extra_headers=extra_headers_config,
|
||||
)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_hook_headers_override_static_headers(self):
|
||||
"""Hook headers should take precedence over static_headers."""
|
||||
manager = MCPServerManager()
|
||||
server = self._make_server(
|
||||
static_headers={"Authorization": "Bearer static-token", "X-Static": "yes"}
|
||||
)
|
||||
|
||||
hook_headers = {"Authorization": "Bearer hook-signed-jwt"}
|
||||
|
||||
captured_extra_headers: Dict[str, Any] = {}
|
||||
|
||||
async def fake_create_mcp_client(
|
||||
server, mcp_auth_header=None, extra_headers=None, stdio_env=None
|
||||
):
|
||||
captured_extra_headers["value"] = extra_headers
|
||||
mock_client = MagicMock()
|
||||
mock_client.call_tool = AsyncMock(return_value=MagicMock())
|
||||
return mock_client
|
||||
|
||||
with patch.object(
|
||||
manager, "_create_mcp_client", side_effect=fake_create_mcp_client
|
||||
):
|
||||
with patch.object(manager, "_build_stdio_env", return_value=None):
|
||||
try:
|
||||
await manager._call_regular_mcp_tool(
|
||||
mcp_server=server,
|
||||
original_tool_name="test_tool",
|
||||
arguments={"key": "val"},
|
||||
tasks=[],
|
||||
mcp_auth_header=None,
|
||||
mcp_server_auth_headers=None,
|
||||
oauth2_headers=None,
|
||||
raw_headers=None,
|
||||
proxy_logging_obj=None,
|
||||
hook_extra_headers=hook_headers,
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
headers = captured_extra_headers.get("value", {})
|
||||
assert headers["Authorization"] == "Bearer hook-signed-jwt"
|
||||
assert headers["X-Static"] == "yes"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_no_hook_headers_preserves_existing_behavior(self):
|
||||
"""When hook_extra_headers is None, existing header logic is unchanged."""
|
||||
manager = MCPServerManager()
|
||||
server = self._make_server(
|
||||
static_headers={"X-Static": "static-value"}
|
||||
)
|
||||
|
||||
captured_extra_headers: Dict[str, Any] = {}
|
||||
|
||||
async def fake_create_mcp_client(
|
||||
server, mcp_auth_header=None, extra_headers=None, stdio_env=None
|
||||
):
|
||||
captured_extra_headers["value"] = extra_headers
|
||||
mock_client = MagicMock()
|
||||
mock_client.call_tool = AsyncMock(return_value=MagicMock())
|
||||
return mock_client
|
||||
|
||||
with patch.object(
|
||||
manager, "_create_mcp_client", side_effect=fake_create_mcp_client
|
||||
):
|
||||
with patch.object(manager, "_build_stdio_env", return_value=None):
|
||||
try:
|
||||
await manager._call_regular_mcp_tool(
|
||||
mcp_server=server,
|
||||
original_tool_name="test_tool",
|
||||
arguments={"key": "val"},
|
||||
tasks=[],
|
||||
mcp_auth_header=None,
|
||||
mcp_server_auth_headers=None,
|
||||
oauth2_headers=None,
|
||||
raw_headers=None,
|
||||
proxy_logging_obj=None,
|
||||
hook_extra_headers=None,
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
headers = captured_extra_headers.get("value", {})
|
||||
assert headers == {"X-Static": "static-value"}
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_hook_headers_merge_with_oauth2(self):
|
||||
"""Hook headers merge on top of OAuth2 headers."""
|
||||
manager = MCPServerManager()
|
||||
server = MCPServer(
|
||||
server_id="test-id",
|
||||
name="Test Server",
|
||||
server_name="test_server",
|
||||
url="https://example.com",
|
||||
transport=MCPTransport.http,
|
||||
auth_type=MCPAuth.oauth2,
|
||||
)
|
||||
|
||||
captured_extra_headers: Dict[str, Any] = {}
|
||||
|
||||
async def fake_create_mcp_client(
|
||||
server, mcp_auth_header=None, extra_headers=None, stdio_env=None
|
||||
):
|
||||
captured_extra_headers["value"] = extra_headers
|
||||
mock_client = MagicMock()
|
||||
mock_client.call_tool = AsyncMock(return_value=MagicMock())
|
||||
return mock_client
|
||||
|
||||
with patch.object(
|
||||
manager, "_create_mcp_client", side_effect=fake_create_mcp_client
|
||||
):
|
||||
with patch.object(manager, "_build_stdio_env", return_value=None):
|
||||
try:
|
||||
await manager._call_regular_mcp_tool(
|
||||
mcp_server=server,
|
||||
original_tool_name="test_tool",
|
||||
arguments={"key": "val"},
|
||||
tasks=[],
|
||||
mcp_auth_header=None,
|
||||
mcp_server_auth_headers=None,
|
||||
oauth2_headers={
|
||||
"Authorization": "Bearer oauth2-token",
|
||||
"X-OAuth": "yes",
|
||||
},
|
||||
raw_headers=None,
|
||||
proxy_logging_obj=None,
|
||||
hook_extra_headers={
|
||||
"Authorization": "Bearer hook-jwt",
|
||||
"X-Trace-Id": "trace-123",
|
||||
},
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
headers = captured_extra_headers.get("value", {})
|
||||
assert headers["Authorization"] == "Bearer hook-jwt"
|
||||
assert headers["X-OAuth"] == "yes"
|
||||
assert headers["X-Trace-Id"] == "trace-123"
|
||||
|
||||
|
||||
class TestUserAPIKeyAuthJwtClaims:
|
||||
"""Tests that UserAPIKeyAuth correctly carries jwt_claims."""
|
||||
|
||||
def test_jwt_claims_field_defaults_to_none(self):
|
||||
auth = UserAPIKeyAuth(api_key="test-key")
|
||||
assert auth.jwt_claims is None
|
||||
|
||||
def test_jwt_claims_field_accepts_dict(self):
|
||||
claims = {"sub": "user-123", "iss": "litellm", "exp": 9999999999}
|
||||
auth = UserAPIKeyAuth(api_key="test-key", jwt_claims=claims)
|
||||
assert auth.jwt_claims == claims
|
||||
assert auth.jwt_claims["sub"] == "user-123"
|
||||
|
||||
def test_jwt_claims_backward_compatible_without_field(self):
|
||||
"""Existing code that doesn't pass jwt_claims should still work."""
|
||||
auth = UserAPIKeyAuth(
|
||||
api_key="test-key",
|
||||
user_id="user-1",
|
||||
team_id="team-1",
|
||||
)
|
||||
assert auth.jwt_claims is None
|
||||
assert auth.user_id == "user-1"
|
||||
|
||||
def test_jwt_claims_set_after_construction(self):
|
||||
"""Virtual-key fast path sets jwt_claims after the object is created."""
|
||||
auth = UserAPIKeyAuth(api_key="test-key")
|
||||
assert auth.jwt_claims is None
|
||||
|
||||
claims = {"sub": "user-456", "iss": "okta", "groups": ["admin"]}
|
||||
auth.jwt_claims = claims
|
||||
assert auth.jwt_claims == claims
|
||||
assert auth.jwt_claims["groups"] == ["admin"]
|
||||
File diff suppressed because it is too large
Load Diff
@@ -2062,7 +2062,14 @@ async def test_list_team_v2_security_check_non_admin_user():
|
||||
user_id="non_admin_user_123",
|
||||
)
|
||||
|
||||
with patch("litellm.proxy.proxy_server.prisma_client") as mock_prisma_client:
|
||||
with patch("litellm.proxy.proxy_server.prisma_client") as mock_prisma_client, \
|
||||
patch("litellm.proxy.proxy_server.user_api_key_cache"), \
|
||||
patch("litellm.proxy.proxy_server.proxy_logging_obj"), \
|
||||
patch(
|
||||
"litellm.proxy.management_endpoints.team_endpoints.get_user_object",
|
||||
new_callable=AsyncMock,
|
||||
return_value=None,
|
||||
):
|
||||
mock_prisma_client.return_value = MagicMock() # Mock non-None prisma client
|
||||
|
||||
# Should raise HTTPException with 401 status
|
||||
@@ -2103,7 +2110,14 @@ async def test_list_team_v2_security_check_non_admin_user_other_user():
|
||||
user_id="non_admin_user_123",
|
||||
)
|
||||
|
||||
with patch("litellm.proxy.proxy_server.prisma_client") as mock_prisma_client:
|
||||
with patch("litellm.proxy.proxy_server.prisma_client") as mock_prisma_client, \
|
||||
patch("litellm.proxy.proxy_server.user_api_key_cache"), \
|
||||
patch("litellm.proxy.proxy_server.proxy_logging_obj"), \
|
||||
patch(
|
||||
"litellm.proxy.management_endpoints.team_endpoints.get_user_object",
|
||||
new_callable=AsyncMock,
|
||||
return_value=None,
|
||||
):
|
||||
mock_prisma_client.return_value = MagicMock() # Mock non-None prisma client
|
||||
|
||||
# Should raise HTTPException with 401 status
|
||||
@@ -2142,19 +2156,21 @@ async def test_list_team_v2_security_check_non_admin_user_own_teams():
|
||||
user_id="non_admin_user_123",
|
||||
)
|
||||
|
||||
with patch("litellm.proxy.proxy_server.prisma_client") as mock_prisma_client:
|
||||
with patch("litellm.proxy.proxy_server.prisma_client") as mock_prisma_client, \
|
||||
patch("litellm.proxy.proxy_server.user_api_key_cache"), \
|
||||
patch("litellm.proxy.proxy_server.proxy_logging_obj"):
|
||||
# Mock prisma client and database operations
|
||||
mock_db = Mock()
|
||||
mock_prisma_client.db = mock_db
|
||||
|
||||
# Mock user lookup
|
||||
mock_user_object = Mock()
|
||||
mock_user_object.model_dump.return_value = {
|
||||
"user_id": "non_admin_user_123",
|
||||
"teams": ["team_1", "team_2"],
|
||||
}
|
||||
mock_db.litellm_usertable.find_unique = AsyncMock(return_value=mock_user_object)
|
||||
|
||||
|
||||
# Mock get_user_object to return a user with teams
|
||||
from litellm.proxy._types import LiteLLM_UserTable
|
||||
|
||||
mock_user = LiteLLM_UserTable(
|
||||
user_id="non_admin_user_123",
|
||||
teams=["team_1", "team_2"],
|
||||
)
|
||||
|
||||
# Mock team lookup
|
||||
mock_teams = [
|
||||
Mock(model_dump=lambda: {"team_id": "team_1", "team_alias": "Team 1"}),
|
||||
@@ -2163,21 +2179,26 @@ async def test_list_team_v2_security_check_non_admin_user_own_teams():
|
||||
mock_db.litellm_teamtable.find_many = AsyncMock(return_value=mock_teams)
|
||||
mock_db.litellm_teamtable.count = AsyncMock(return_value=2)
|
||||
|
||||
# Should NOT raise an exception
|
||||
result = await list_team_v2(
|
||||
http_request=mock_request,
|
||||
user_id="non_admin_user_123", # Non-admin querying their own teams
|
||||
user_api_key_dict=mock_user_api_key_dict_non_admin,
|
||||
team_id=None,
|
||||
page=1,
|
||||
page_size=10,
|
||||
status=None,
|
||||
)
|
||||
with patch(
|
||||
"litellm.proxy.management_endpoints.team_endpoints.get_user_object",
|
||||
new_callable=AsyncMock,
|
||||
return_value=mock_user,
|
||||
):
|
||||
# Should NOT raise an exception
|
||||
result = await list_team_v2(
|
||||
http_request=mock_request,
|
||||
user_id="non_admin_user_123", # Non-admin querying their own teams
|
||||
user_api_key_dict=mock_user_api_key_dict_non_admin,
|
||||
team_id=None,
|
||||
page=1,
|
||||
page_size=10,
|
||||
status=None,
|
||||
)
|
||||
|
||||
# Should return results without error
|
||||
assert "teams" in result
|
||||
assert "total" in result
|
||||
assert result["total"] == 2
|
||||
# Should return results without error
|
||||
assert "teams" in result
|
||||
assert "total" in result
|
||||
assert result["total"] == 2
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@@ -2293,27 +2314,280 @@ async def test_list_team_v2_with_status_deleted():
|
||||
assert len(result["teams"]) == 2
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_list_team_v2_org_admin_sees_org_teams():
|
||||
"""
|
||||
Test that an org admin (internal_user role with org_admin membership)
|
||||
can list teams scoped to their organisations without getting a 401.
|
||||
"""
|
||||
from datetime import datetime
|
||||
from unittest.mock import AsyncMock, Mock, patch
|
||||
|
||||
from fastapi import Request
|
||||
|
||||
from litellm.proxy._types import (
|
||||
LiteLLM_OrganizationMembershipTable,
|
||||
LiteLLM_UserTable,
|
||||
LitellmUserRoles,
|
||||
UserAPIKeyAuth,
|
||||
)
|
||||
from litellm.proxy.management_endpoints.team_endpoints import list_team_v2
|
||||
|
||||
mock_request = Mock(spec=Request)
|
||||
mock_user_api_key_dict = UserAPIKeyAuth(
|
||||
user_role=LitellmUserRoles.INTERNAL_USER,
|
||||
user_id="org_admin_user",
|
||||
)
|
||||
|
||||
mock_user = LiteLLM_UserTable(
|
||||
user_id="org_admin_user",
|
||||
teams=[],
|
||||
organization_memberships=[
|
||||
LiteLLM_OrganizationMembershipTable(
|
||||
user_id="org_admin_user",
|
||||
organization_id="org_A",
|
||||
user_role="org_admin",
|
||||
spend=0.0,
|
||||
created_at=datetime.now(),
|
||||
updated_at=datetime.now(),
|
||||
),
|
||||
],
|
||||
)
|
||||
|
||||
with patch("litellm.proxy.proxy_server.prisma_client") as mock_prisma, \
|
||||
patch("litellm.proxy.proxy_server.user_api_key_cache"), \
|
||||
patch("litellm.proxy.proxy_server.proxy_logging_obj"), \
|
||||
patch(
|
||||
"litellm.proxy.management_endpoints.team_endpoints.get_user_object",
|
||||
new_callable=AsyncMock,
|
||||
return_value=mock_user,
|
||||
):
|
||||
mock_db = Mock()
|
||||
mock_prisma.db = mock_db
|
||||
|
||||
mock_team = Mock()
|
||||
mock_team.model_dump.return_value = {
|
||||
"team_id": "team_in_org_A",
|
||||
"team_alias": "Org A Team",
|
||||
"organization_id": "org_A",
|
||||
"members_with_roles": [{"user_id": "u1", "role": "user"}],
|
||||
}
|
||||
mock_db.litellm_teamtable.find_many = AsyncMock(return_value=[mock_team])
|
||||
mock_db.litellm_teamtable.count = AsyncMock(return_value=1)
|
||||
|
||||
result = await list_team_v2(
|
||||
http_request=mock_request,
|
||||
user_id=None,
|
||||
organization_id=None,
|
||||
team_id=None,
|
||||
team_alias=None,
|
||||
user_api_key_dict=mock_user_api_key_dict,
|
||||
page=1,
|
||||
page_size=10,
|
||||
sort_by=None,
|
||||
sort_order="asc",
|
||||
status=None,
|
||||
)
|
||||
|
||||
assert result["total"] == 1
|
||||
assert len(result["teams"]) == 1
|
||||
assert result["teams"][0].members_count == 1
|
||||
|
||||
# Verify org-scoped where clause
|
||||
where = mock_db.litellm_teamtable.find_many.call_args.kwargs["where"]
|
||||
assert where["organization_id"] == {"in": ["org_A"]}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_list_team_v2_org_admin_cannot_view_other_orgs():
|
||||
"""
|
||||
Test that an org admin is rejected with 403 when filtering by an
|
||||
organisation they do not administer.
|
||||
"""
|
||||
from datetime import datetime
|
||||
from unittest.mock import AsyncMock, Mock, patch
|
||||
|
||||
from fastapi import HTTPException, Request
|
||||
|
||||
from litellm.proxy._types import (
|
||||
LiteLLM_OrganizationMembershipTable,
|
||||
LiteLLM_UserTable,
|
||||
LitellmUserRoles,
|
||||
UserAPIKeyAuth,
|
||||
)
|
||||
from litellm.proxy.management_endpoints.team_endpoints import list_team_v2
|
||||
|
||||
mock_request = Mock(spec=Request)
|
||||
mock_user_api_key_dict = UserAPIKeyAuth(
|
||||
user_role=LitellmUserRoles.INTERNAL_USER,
|
||||
user_id="org_admin_user",
|
||||
)
|
||||
|
||||
mock_user = LiteLLM_UserTable(
|
||||
user_id="org_admin_user",
|
||||
teams=[],
|
||||
organization_memberships=[
|
||||
LiteLLM_OrganizationMembershipTable(
|
||||
user_id="org_admin_user",
|
||||
organization_id="org_A",
|
||||
user_role="org_admin",
|
||||
spend=0.0,
|
||||
created_at=datetime.now(),
|
||||
updated_at=datetime.now(),
|
||||
),
|
||||
],
|
||||
)
|
||||
|
||||
with patch("litellm.proxy.proxy_server.prisma_client") as mock_prisma, \
|
||||
patch("litellm.proxy.proxy_server.user_api_key_cache"), \
|
||||
patch("litellm.proxy.proxy_server.proxy_logging_obj"), \
|
||||
patch(
|
||||
"litellm.proxy.management_endpoints.team_endpoints.get_user_object",
|
||||
new_callable=AsyncMock,
|
||||
return_value=mock_user,
|
||||
):
|
||||
mock_prisma.db = Mock()
|
||||
|
||||
with pytest.raises(HTTPException) as exc_info:
|
||||
await list_team_v2(
|
||||
http_request=mock_request,
|
||||
user_id=None,
|
||||
organization_id="org_B", # not their org
|
||||
team_id=None,
|
||||
team_alias=None,
|
||||
user_api_key_dict=mock_user_api_key_dict,
|
||||
page=1,
|
||||
page_size=10,
|
||||
sort_by=None,
|
||||
sort_order="asc",
|
||||
status=None,
|
||||
)
|
||||
|
||||
assert exc_info.value.status_code == 403
|
||||
assert "only view teams within your organizations" in str(
|
||||
exc_info.value.detail
|
||||
).lower()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_list_team_v2_org_admin_with_user_id_returns_user_teams():
|
||||
"""
|
||||
Test that an org admin passing user_id gets that user's direct team
|
||||
memberships (not all org teams).
|
||||
"""
|
||||
from datetime import datetime
|
||||
from unittest.mock import AsyncMock, Mock, patch
|
||||
|
||||
from fastapi import Request
|
||||
|
||||
from litellm.proxy._types import (
|
||||
LiteLLM_OrganizationMembershipTable,
|
||||
LiteLLM_UserTable,
|
||||
LitellmUserRoles,
|
||||
UserAPIKeyAuth,
|
||||
)
|
||||
from litellm.proxy.management_endpoints.team_endpoints import list_team_v2
|
||||
|
||||
mock_request = Mock(spec=Request)
|
||||
mock_user_api_key_dict = UserAPIKeyAuth(
|
||||
user_role=LitellmUserRoles.INTERNAL_USER,
|
||||
user_id="org_admin_user",
|
||||
)
|
||||
|
||||
mock_org_admin = LiteLLM_UserTable(
|
||||
user_id="org_admin_user",
|
||||
teams=["team_1"],
|
||||
organization_memberships=[
|
||||
LiteLLM_OrganizationMembershipTable(
|
||||
user_id="org_admin_user",
|
||||
organization_id="org_A",
|
||||
user_role="org_admin",
|
||||
spend=0.0,
|
||||
created_at=datetime.now(),
|
||||
updated_at=datetime.now(),
|
||||
),
|
||||
],
|
||||
)
|
||||
|
||||
# The target user whose teams we want to list
|
||||
mock_target_user = LiteLLM_UserTable(
|
||||
user_id="target_user",
|
||||
teams=["team_X", "team_Y"],
|
||||
)
|
||||
|
||||
call_count = 0
|
||||
|
||||
async def mock_get_user_object(**kwargs):
|
||||
nonlocal call_count
|
||||
call_count += 1
|
||||
# First call: org admin lookup in list_team_v2
|
||||
# Second call: target user lookup in _build_team_list_where_conditions
|
||||
if call_count == 1:
|
||||
return mock_org_admin
|
||||
return mock_target_user
|
||||
|
||||
with patch("litellm.proxy.proxy_server.prisma_client") as mock_prisma, \
|
||||
patch("litellm.proxy.proxy_server.user_api_key_cache"), \
|
||||
patch("litellm.proxy.proxy_server.proxy_logging_obj"), \
|
||||
patch(
|
||||
"litellm.proxy.management_endpoints.team_endpoints.get_user_object",
|
||||
side_effect=mock_get_user_object,
|
||||
):
|
||||
mock_db = Mock()
|
||||
mock_prisma.db = mock_db
|
||||
|
||||
mock_team = Mock()
|
||||
mock_team.model_dump.return_value = {
|
||||
"team_id": "team_X",
|
||||
"team_alias": "Target Team",
|
||||
"members_with_roles": [{"user_id": "target_user", "role": "user"}],
|
||||
}
|
||||
mock_db.litellm_teamtable.find_many = AsyncMock(return_value=[mock_team])
|
||||
mock_db.litellm_teamtable.count = AsyncMock(return_value=1)
|
||||
|
||||
result = await list_team_v2(
|
||||
http_request=mock_request,
|
||||
user_id="target_user",
|
||||
organization_id=None,
|
||||
team_id=None,
|
||||
team_alias=None,
|
||||
user_api_key_dict=mock_user_api_key_dict,
|
||||
page=1,
|
||||
page_size=10,
|
||||
sort_by=None,
|
||||
sort_order="asc",
|
||||
status=None,
|
||||
)
|
||||
|
||||
assert result["total"] == 1
|
||||
|
||||
# Verify the where clause filters by user's teams, not org scope
|
||||
where = mock_db.litellm_teamtable.find_many.call_args.kwargs["where"]
|
||||
assert where["team_id"] == {"in": ["team_X", "team_Y"]}
|
||||
assert "organization_id" not in where
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_list_team_v2_with_invalid_status():
|
||||
"""
|
||||
Test that invalid status parameter raises HTTPException.
|
||||
"""
|
||||
from unittest.mock import Mock, patch
|
||||
|
||||
|
||||
from fastapi import HTTPException, Request
|
||||
|
||||
|
||||
from litellm.proxy._types import LitellmUserRoles, UserAPIKeyAuth
|
||||
from litellm.proxy.management_endpoints.team_endpoints import list_team_v2
|
||||
|
||||
|
||||
# Mock request
|
||||
mock_request = Mock(spec=Request)
|
||||
|
||||
|
||||
# Mock admin user
|
||||
mock_user_api_key_dict_admin = UserAPIKeyAuth(
|
||||
user_role=LitellmUserRoles.PROXY_ADMIN,
|
||||
user_id="admin_user_123",
|
||||
)
|
||||
|
||||
|
||||
mock_prisma_client = Mock()
|
||||
|
||||
# Mock prisma_client to be non-None
|
||||
|
||||
@@ -0,0 +1,188 @@
|
||||
"""Unit tests for litellm.setup_wizard — pure functions only, no network calls."""
|
||||
|
||||
from litellm.setup_wizard import SetupWizard, _yaml_escape
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# _yaml_escape
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_yaml_escape_plain():
|
||||
assert _yaml_escape("sk-abc123") == "sk-abc123"
|
||||
|
||||
|
||||
def test_yaml_escape_double_quote():
|
||||
assert _yaml_escape('sk-ab"cd') == 'sk-ab\\"cd'
|
||||
|
||||
|
||||
def test_yaml_escape_backslash():
|
||||
assert _yaml_escape("sk-ab\\cd") == "sk-ab\\\\cd"
|
||||
|
||||
|
||||
def test_yaml_escape_combined():
|
||||
assert _yaml_escape('ab\\"cd') == 'ab\\\\\\"cd'
|
||||
|
||||
|
||||
def test_yaml_escape_newline():
|
||||
assert _yaml_escape("sk-abc\ndef") == "sk-abc\\ndef"
|
||||
|
||||
|
||||
def test_yaml_escape_carriage_return():
|
||||
assert _yaml_escape("sk-abc\rdef") == "sk-abc\\rdef"
|
||||
|
||||
|
||||
def test_yaml_escape_tab():
|
||||
assert _yaml_escape("sk-abc\tdef") == "sk-abc\\tdef"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# SetupWizard._build_config
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
_OPENAI = {
|
||||
"id": "openai",
|
||||
"name": "OpenAI",
|
||||
"env_key": "OPENAI_API_KEY",
|
||||
"models": ["gpt-4o", "gpt-4o-mini"],
|
||||
"test_model": "gpt-4o-mini",
|
||||
}
|
||||
|
||||
_ANTHROPIC = {
|
||||
"id": "anthropic",
|
||||
"name": "Anthropic",
|
||||
"env_key": "ANTHROPIC_API_KEY",
|
||||
"models": ["claude-opus-4-6"],
|
||||
"test_model": "claude-haiku-4-5-20251001",
|
||||
}
|
||||
|
||||
_AZURE = {
|
||||
"id": "azure",
|
||||
"name": "Azure OpenAI",
|
||||
"env_key": "AZURE_API_KEY",
|
||||
"models": [],
|
||||
"test_model": None,
|
||||
"needs_api_base": True,
|
||||
"api_base_hint": "https://<resource>.openai.azure.com/",
|
||||
"api_version": "2024-07-01-preview",
|
||||
}
|
||||
|
||||
_OLLAMA = {
|
||||
"id": "ollama",
|
||||
"name": "Ollama",
|
||||
"env_key": None,
|
||||
"models": ["ollama/llama3.2"],
|
||||
"test_model": None,
|
||||
"api_base": "http://localhost:11434",
|
||||
}
|
||||
|
||||
|
||||
def test_build_config_basic_openai():
|
||||
config = SetupWizard._build_config(
|
||||
[_OPENAI],
|
||||
{"OPENAI_API_KEY": "sk-test"},
|
||||
"sk-master",
|
||||
)
|
||||
assert "model_list:" in config
|
||||
assert "model_name: gpt-4o" in config
|
||||
assert "model: gpt-4o" in config
|
||||
assert "api_key: os.environ/OPENAI_API_KEY" in config
|
||||
assert 'master_key: "sk-master"' in config
|
||||
|
||||
|
||||
def test_build_config_skipped_provider_omitted():
|
||||
"""Provider with no key in env_vars should not appear in model_list."""
|
||||
config = SetupWizard._build_config(
|
||||
[_OPENAI, _ANTHROPIC],
|
||||
{"ANTHROPIC_API_KEY": "sk-ant-test"}, # OpenAI key missing
|
||||
"sk-master",
|
||||
)
|
||||
assert "gpt-4o" not in config
|
||||
assert "claude-opus-4-6" in config
|
||||
|
||||
|
||||
def test_build_config_env_vars_written_escaped():
|
||||
"""API keys with special chars should be YAML-escaped."""
|
||||
config = SetupWizard._build_config(
|
||||
[_OPENAI],
|
||||
{"OPENAI_API_KEY": 'sk-ab"cd'},
|
||||
"sk-master",
|
||||
)
|
||||
assert 'OPENAI_API_KEY: "sk-ab\\"cd"' in config
|
||||
|
||||
|
||||
def test_build_config_master_key_quoted():
|
||||
"""master_key must be quoted in YAML to handle special characters."""
|
||||
config = SetupWizard._build_config(
|
||||
[_OPENAI],
|
||||
{"OPENAI_API_KEY": "sk-test"},
|
||||
'sk-master"special',
|
||||
)
|
||||
assert 'master_key: "sk-master\\"special"' in config
|
||||
|
||||
|
||||
def test_build_config_does_not_mutate_env_vars():
|
||||
"""_build_config must not modify the caller's env_vars dict."""
|
||||
env_vars = {
|
||||
"AZURE_API_KEY": "az-key",
|
||||
"_LITELLM_AZURE_API_BASE_AZURE": "https://my.azure.com",
|
||||
"_LITELLM_AZURE_DEPLOYMENT_AZURE": "my-deployment",
|
||||
}
|
||||
original_keys = set(env_vars.keys())
|
||||
SetupWizard._build_config([_AZURE], env_vars, "sk-master")
|
||||
assert set(env_vars.keys()) == original_keys
|
||||
|
||||
|
||||
def test_build_config_azure_uses_deployment_name():
|
||||
env_vars = {
|
||||
"AZURE_API_KEY": "az-key",
|
||||
"_LITELLM_AZURE_API_BASE_AZURE": "https://my.azure.com",
|
||||
"_LITELLM_AZURE_DEPLOYMENT_AZURE": "my-gpt4o",
|
||||
}
|
||||
config = SetupWizard._build_config([_AZURE], env_vars, "sk-master")
|
||||
assert "model: azure/my-gpt4o" in config
|
||||
assert "model_name: azure-my-gpt4o" in config
|
||||
# api_base must be quoted to survive YAML special chars
|
||||
assert 'api_base: "https://my.azure.com"' in config
|
||||
|
||||
|
||||
def test_build_config_azure_no_deployment_skipped():
|
||||
"""Azure without a deployment name should emit nothing (not fallback to gpt-4o)."""
|
||||
env_vars = {"AZURE_API_KEY": "az-key"} # no deployment sentinel
|
||||
config = SetupWizard._build_config([_AZURE], env_vars, "sk-master")
|
||||
# No azure model entry should be emitted when deployment name is absent
|
||||
assert "model: azure/" not in config
|
||||
|
||||
|
||||
def test_build_config_no_display_name_collision_openai_and_azure():
|
||||
"""OpenAI gpt-4o and azure gpt-4o should get distinct model_name values."""
|
||||
env_vars = {
|
||||
"OPENAI_API_KEY": "sk-openai",
|
||||
"AZURE_API_KEY": "az-key",
|
||||
"_LITELLM_AZURE_DEPLOYMENT_AZURE": "gpt-4o",
|
||||
}
|
||||
config = SetupWizard._build_config([_OPENAI, _AZURE], env_vars, "sk-master")
|
||||
assert "model_name: gpt-4o" in config # OpenAI
|
||||
assert "model_name: azure-gpt-4o" in config # Azure — qualified
|
||||
|
||||
|
||||
def test_build_config_ollama_no_api_key_line():
|
||||
"""Ollama has no env_key — config should not contain an api_key line for it."""
|
||||
config = SetupWizard._build_config([_OLLAMA], {}, "sk-master")
|
||||
assert "ollama/llama3.2" in config
|
||||
assert "api_key:" not in config
|
||||
|
||||
|
||||
def test_build_config_master_key_in_general_settings():
|
||||
"""master_key is written to general_settings."""
|
||||
config = SetupWizard._build_config([_OPENAI], {"OPENAI_API_KEY": "k"}, "sk-m")
|
||||
assert 'master_key: "sk-m"' in config
|
||||
|
||||
|
||||
def test_build_config_internal_sentinel_keys_excluded():
|
||||
"""_LITELLM_ prefixed sentinel keys must not appear in environment_variables."""
|
||||
env_vars = {
|
||||
"OPENAI_API_KEY": "sk-real",
|
||||
"_LITELLM_AZURE_API_BASE_AZURE": "https://x.azure.com",
|
||||
}
|
||||
config = SetupWizard._build_config([_OPENAI], env_vars, "sk-master")
|
||||
assert "_LITELLM_" not in config
|
||||
@@ -20,7 +20,6 @@ import {
|
||||
ToolOutlined,
|
||||
TagsOutlined,
|
||||
AuditOutlined,
|
||||
MessageOutlined,
|
||||
} from "@ant-design/icons";
|
||||
// import {
|
||||
// all_admin_roles,
|
||||
@@ -466,41 +465,6 @@ const Sidebar2: React.FC<SidebarProps> = ({ accessToken, userRole, defaultSelect
|
||||
</ConfigProvider>
|
||||
{isAdminRole(userRole) && !collapsed && <UsageIndicator accessToken={accessToken} width={220} />}
|
||||
|
||||
{/* Pinned "Open Chat" button at bottom */}
|
||||
<div style={{
|
||||
padding: collapsed ? "10px 8px" : "10px 12px",
|
||||
borderTop: "1px solid #f0f0f0",
|
||||
flexShrink: 0,
|
||||
}}>
|
||||
<a
|
||||
href={toHref("chat")}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: collapsed ? "center" : "flex-start",
|
||||
gap: 8,
|
||||
padding: collapsed ? "8px 0" : "8px 10px",
|
||||
borderRadius: 8,
|
||||
background: "#1677ff",
|
||||
color: "#fff",
|
||||
textDecoration: "none",
|
||||
fontSize: 13,
|
||||
fontWeight: 600,
|
||||
transition: "background 0.15s",
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
(e.currentTarget as HTMLAnchorElement).style.background = "#0958d9";
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
(e.currentTarget as HTMLAnchorElement).style.background = "#1677ff";
|
||||
}}
|
||||
>
|
||||
<MessageOutlined style={{ fontSize: 16, flexShrink: 0 }} />
|
||||
{!collapsed && <span>Open Chat</span>}
|
||||
</a>
|
||||
</div>
|
||||
</Sider>
|
||||
</Layout>
|
||||
);
|
||||
|
||||
@@ -8,8 +8,6 @@ import ComplianceUI from "@/components/playground/complianceUI/ComplianceUI";
|
||||
import { TabGroup, TabList, Tab, TabPanels, TabPanel } from "@tremor/react";
|
||||
import useAuthorized from "@/app/(dashboard)/hooks/useAuthorized";
|
||||
import { fetchProxySettings } from "@/utils/proxyUtils";
|
||||
import { useUIConfig } from "@/app/(dashboard)/hooks/uiConfig/useUIConfig";
|
||||
import { MessageOutlined, CloseOutlined } from "@ant-design/icons";
|
||||
|
||||
interface ProxySettings {
|
||||
PROXY_BASE_URL?: string;
|
||||
@@ -19,12 +17,6 @@ interface ProxySettings {
|
||||
export default function PlaygroundPage() {
|
||||
const { accessToken, userRole, userId, disabledPersonalKeyCreation, token } = useAuthorized();
|
||||
const [proxySettings, setProxySettings] = useState<ProxySettings | undefined>(undefined);
|
||||
const [chatBannerDismissed, setChatBannerDismissed] = useState(false);
|
||||
const { data: uiConfig } = useUIConfig();
|
||||
const uiRoot = uiConfig?.server_root_path && uiConfig.server_root_path !== "/"
|
||||
? uiConfig.server_root_path.replace(/\/+$/, "")
|
||||
: "";
|
||||
const chatHref = `${uiRoot}/ui/chat`;
|
||||
|
||||
useEffect(() => {
|
||||
const initializeProxySettings = async () => {
|
||||
@@ -44,64 +36,6 @@ export default function PlaygroundPage() {
|
||||
|
||||
return (
|
||||
<div className="h-full w-full flex flex-col">
|
||||
{!chatBannerDismissed && (
|
||||
<div style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: 16,
|
||||
padding: "10px 20px",
|
||||
background: "#f0f9ff",
|
||||
borderBottom: "1px solid #bae6fd",
|
||||
flexShrink: 0,
|
||||
}}>
|
||||
<span style={{
|
||||
fontSize: 10,
|
||||
fontWeight: 700,
|
||||
color: "#fff",
|
||||
background: "#0ea5e9",
|
||||
borderRadius: 4,
|
||||
padding: "2px 7px",
|
||||
letterSpacing: "0.08em",
|
||||
textTransform: "uppercase",
|
||||
flexShrink: 0,
|
||||
lineHeight: "18px",
|
||||
}}>
|
||||
New
|
||||
</span>
|
||||
<span style={{ flex: 1, color: "#0c4a6e", fontSize: 13.5, lineHeight: 1.5 }}>
|
||||
<strong>Chat UI</strong>
|
||||
{" "}— a ChatGPT-like interface for your users to chat with AI models and MCP tools. Share it with your team.
|
||||
</span>
|
||||
<a
|
||||
href={chatHref}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
style={{
|
||||
display: "inline-flex",
|
||||
alignItems: "center",
|
||||
gap: 5,
|
||||
padding: "5px 14px",
|
||||
borderRadius: 6,
|
||||
background: "#0ea5e9",
|
||||
color: "#fff",
|
||||
fontSize: 12.5,
|
||||
fontWeight: 600,
|
||||
textDecoration: "none",
|
||||
whiteSpace: "nowrap",
|
||||
flexShrink: 0,
|
||||
}}
|
||||
>
|
||||
Open Chat UI →
|
||||
</a>
|
||||
<button
|
||||
onClick={() => setChatBannerDismissed(true)}
|
||||
style={{ background: "none", border: "none", cursor: "pointer", color: "#64748b", padding: 4, flexShrink: 0, lineHeight: 1 }}
|
||||
aria-label="Dismiss"
|
||||
>
|
||||
<CloseOutlined style={{ fontSize: 13 }} />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
<TabGroup className="w-full" style={{ flex: 1, minHeight: 0, display: "flex", flexDirection: "column" }}>
|
||||
<TabList className="mb-0">
|
||||
<Tab>Chat</Tab>
|
||||
|
||||
@@ -2,37 +2,40 @@ import { renderWithProviders, screen } from "../../tests/test-utils";
|
||||
import { vi } from "vitest";
|
||||
import { DebugWarningBanner } from "./DebugWarningBanner";
|
||||
|
||||
const mockUseHealthReadiness = vi.fn();
|
||||
vi.mock("@/app/(dashboard)/hooks/healthReadiness/useHealthReadiness", () => ({
|
||||
useHealthReadiness: () => mockUseHealthReadiness(),
|
||||
useHealthReadiness: vi.fn(),
|
||||
}));
|
||||
|
||||
import { useHealthReadiness } from "@/app/(dashboard)/hooks/healthReadiness/useHealthReadiness";
|
||||
|
||||
describe("DebugWarningBanner", () => {
|
||||
afterEach(() => {
|
||||
vi.resetAllMocks();
|
||||
});
|
||||
|
||||
it("should render", () => {
|
||||
mockUseHealthReadiness.mockReturnValue({ data: { is_detailed_debug: true } });
|
||||
vi.mocked(useHealthReadiness).mockReturnValue({ data: { is_detailed_debug: true } } as any);
|
||||
renderWithProviders(<DebugWarningBanner />);
|
||||
expect(screen.getByText(/Performance Warning/i)).toBeInTheDocument();
|
||||
expect(screen.getByRole("alert")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should render nothing when debug mode is disabled", () => {
|
||||
mockUseHealthReadiness.mockReturnValue({ data: { is_detailed_debug: false } });
|
||||
const { container } = renderWithProviders(<DebugWarningBanner />);
|
||||
expect(container.firstChild).toBeNull();
|
||||
});
|
||||
|
||||
it("should render nothing when health data is undefined", () => {
|
||||
mockUseHealthReadiness.mockReturnValue({ data: undefined });
|
||||
const { container } = renderWithProviders(<DebugWarningBanner />);
|
||||
expect(container.firstChild).toBeNull();
|
||||
it("should show warning when detailed debug mode is active", () => {
|
||||
vi.mocked(useHealthReadiness).mockReturnValue({ data: { is_detailed_debug: true } } as any);
|
||||
renderWithProviders(<DebugWarningBanner />);
|
||||
expect(screen.getByText(/Performance Warning: Detailed Debug Mode Active/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should mention LITELLM_LOG=DEBUG in the description", () => {
|
||||
mockUseHealthReadiness.mockReturnValue({ data: { is_detailed_debug: true } });
|
||||
vi.mocked(useHealthReadiness).mockReturnValue({ data: { is_detailed_debug: true } } as any);
|
||||
renderWithProviders(<DebugWarningBanner />);
|
||||
expect(screen.getByText(/LITELLM_LOG=DEBUG/)).toBeInTheDocument();
|
||||
expect(screen.getByText("LITELLM_LOG=DEBUG")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should render nothing when is_detailed_debug is false", () => {
|
||||
vi.mocked(useHealthReadiness).mockReturnValue({ data: { is_detailed_debug: false } } as any);
|
||||
const { container } = renderWithProviders(<DebugWarningBanner />);
|
||||
expect(container).toBeEmptyDOMElement();
|
||||
});
|
||||
|
||||
it("should render nothing when health data is undefined", () => {
|
||||
vi.mocked(useHealthReadiness).mockReturnValue({ data: undefined } as any);
|
||||
const { container } = renderWithProviders(<DebugWarningBanner />);
|
||||
expect(container).toBeEmptyDOMElement();
|
||||
});
|
||||
});
|
||||
|
||||
+6
-20
@@ -1,34 +1,20 @@
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { renderWithProviders, screen } from "../../../tests/test-utils";
|
||||
import { vi } from "vitest";
|
||||
import ExportFormatSelector from "./ExportFormatSelector";
|
||||
|
||||
describe("ExportFormatSelector", () => {
|
||||
it("should render", () => {
|
||||
render(<ExportFormatSelector value="csv" onChange={vi.fn()} />);
|
||||
renderWithProviders(<ExportFormatSelector value="csv" onChange={vi.fn()} />);
|
||||
expect(screen.getByText("Format")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should display the current value as csv", () => {
|
||||
render(<ExportFormatSelector value="csv" onChange={vi.fn()} />);
|
||||
it("should display the current value", () => {
|
||||
renderWithProviders(<ExportFormatSelector value="csv" onChange={vi.fn()} />);
|
||||
expect(screen.getByText("CSV (Excel, Google Sheets)")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should display the current value as json", () => {
|
||||
render(<ExportFormatSelector value="json" onChange={vi.fn()} />);
|
||||
it("should display JSON label when json is selected", () => {
|
||||
renderWithProviders(<ExportFormatSelector value="json" onChange={vi.fn()} />);
|
||||
expect(screen.getByText("JSON (includes metadata)")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should call onChange when a different format is selected", async () => {
|
||||
const onChange = vi.fn();
|
||||
const user = userEvent.setup();
|
||||
render(<ExportFormatSelector value="csv" onChange={onChange} />);
|
||||
|
||||
// Open the Ant Design Select dropdown
|
||||
await user.click(screen.getByText("CSV (Excel, Google Sheets)"));
|
||||
// Select JSON option from the dropdown
|
||||
const jsonOption = await screen.findByText("JSON (includes metadata)");
|
||||
await user.click(jsonOption);
|
||||
expect(onChange).toHaveBeenCalledWith("json", expect.anything());
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,37 +1,59 @@
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { renderWithProviders, screen } from "../../../tests/test-utils";
|
||||
import ExportSummary from "./ExportSummary";
|
||||
|
||||
describe("ExportSummary", () => {
|
||||
const dateRange = {
|
||||
from: new Date("2025-01-01"),
|
||||
to: new Date("2025-01-31"),
|
||||
};
|
||||
|
||||
it("should render", () => {
|
||||
render(<ExportSummary dateRange={dateRange} selectedFilters={[]} />);
|
||||
expect(screen.getByText(/2025/)).toBeInTheDocument();
|
||||
const dateRange = {
|
||||
from: new Date("2024-01-01"),
|
||||
to: new Date("2024-01-31"),
|
||||
};
|
||||
const { container } = renderWithProviders(
|
||||
<ExportSummary dateRange={dateRange} selectedFilters={[]} />
|
||||
);
|
||||
expect(container).not.toBeEmptyDOMElement();
|
||||
});
|
||||
|
||||
it("should display formatted date range", () => {
|
||||
render(<ExportSummary dateRange={dateRange} selectedFilters={[]} />);
|
||||
// Pin locale to en-US so test is deterministic regardless of CI runner locale
|
||||
const expectedFrom = dateRange.from!.toLocaleDateString("en-US");
|
||||
const expectedTo = dateRange.to!.toLocaleDateString("en-US");
|
||||
expect(screen.getByText(`${expectedFrom} - ${expectedTo}`)).toBeInTheDocument();
|
||||
it("should display the date range", () => {
|
||||
const from = new Date(2024, 0, 1);
|
||||
const to = new Date(2024, 0, 31);
|
||||
const dateRange = { from, to };
|
||||
renderWithProviders(
|
||||
<ExportSummary dateRange={dateRange} selectedFilters={[]} />
|
||||
);
|
||||
expect(screen.getByText(new RegExp(from.toLocaleDateString()))).toBeInTheDocument();
|
||||
expect(screen.getByText(new RegExp(to.toLocaleDateString()))).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should show filter count when filters are selected", () => {
|
||||
const dateRange = {
|
||||
from: new Date("2024-01-01"),
|
||||
to: new Date("2024-01-31"),
|
||||
};
|
||||
renderWithProviders(
|
||||
<ExportSummary dateRange={dateRange} selectedFilters={["team-a", "team-b", "team-c"]} />
|
||||
);
|
||||
expect(screen.getByText(/3 filters/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should show singular 'filter' for one filter", () => {
|
||||
render(<ExportSummary dateRange={dateRange} selectedFilters={["team-a"]} />);
|
||||
const dateRange = {
|
||||
from: new Date("2024-01-01"),
|
||||
to: new Date("2024-01-31"),
|
||||
};
|
||||
renderWithProviders(
|
||||
<ExportSummary dateRange={dateRange} selectedFilters={["team-a"]} />
|
||||
);
|
||||
expect(screen.getByText(/1 filter$/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should show plural 'filters' for multiple filters", () => {
|
||||
render(<ExportSummary dateRange={dateRange} selectedFilters={["team-a", "team-b"]} />);
|
||||
expect(screen.getByText(/2 filters/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should not show filter text when no filters applied", () => {
|
||||
render(<ExportSummary dateRange={dateRange} selectedFilters={[]} />);
|
||||
it("should not show filter count when no filters selected", () => {
|
||||
const dateRange = {
|
||||
from: new Date("2024-01-01"),
|
||||
to: new Date("2024-01-31"),
|
||||
};
|
||||
renderWithProviders(
|
||||
<ExportSummary dateRange={dateRange} selectedFilters={[]} />
|
||||
);
|
||||
expect(screen.queryByText(/filter/)).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,37 +1,46 @@
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { renderWithProviders, screen } from "../../../tests/test-utils";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { vi } from "vitest";
|
||||
import ExportTypeSelector from "./ExportTypeSelector";
|
||||
|
||||
describe("ExportTypeSelector", () => {
|
||||
it("should render", () => {
|
||||
render(<ExportTypeSelector value="daily" onChange={vi.fn()} entityType="team" />);
|
||||
renderWithProviders(
|
||||
<ExportTypeSelector value="daily" onChange={vi.fn()} entityType="team" />
|
||||
);
|
||||
expect(screen.getByText("Export type")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should render all three radio options", () => {
|
||||
render(<ExportTypeSelector value="daily" onChange={vi.fn()} entityType="team" />);
|
||||
expect(screen.getAllByRole("radio")).toHaveLength(3);
|
||||
it("should display entity type in radio labels", () => {
|
||||
renderWithProviders(
|
||||
<ExportTypeSelector value="daily" onChange={vi.fn()} entityType="team" />
|
||||
);
|
||||
expect(screen.getByText(/Day-by-day breakdown by team$/)).toBeInTheDocument();
|
||||
expect(screen.getByText(/Day-by-day breakdown by team and key/)).toBeInTheDocument();
|
||||
expect(screen.getByText(/Day-by-day by team and model/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should interpolate entity type in labels", () => {
|
||||
render(<ExportTypeSelector value="daily" onChange={vi.fn()} entityType="organization" />);
|
||||
it("should display the correct entity type for different entities", () => {
|
||||
renderWithProviders(
|
||||
<ExportTypeSelector value="daily" onChange={vi.fn()} entityType="organization" />
|
||||
);
|
||||
expect(screen.getByText(/Day-by-day breakdown by organization$/)).toBeInTheDocument();
|
||||
expect(screen.getByText(/organization and key/)).toBeInTheDocument();
|
||||
expect(screen.getByText(/organization and model/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should call onChange when a different option is selected", async () => {
|
||||
const onChange = vi.fn();
|
||||
it("should call onChange when a radio option is selected", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<ExportTypeSelector value="daily" onChange={onChange} entityType="team" />);
|
||||
await user.click(screen.getByText(/by team and key/));
|
||||
const onChange = vi.fn();
|
||||
renderWithProviders(
|
||||
<ExportTypeSelector value="daily" onChange={onChange} entityType="team" />
|
||||
);
|
||||
await user.click(screen.getByRole("radio", { name: /Day-by-day breakdown by team and key/i }));
|
||||
expect(onChange).toHaveBeenCalledWith("daily_with_keys");
|
||||
});
|
||||
|
||||
it("should have the correct radio checked based on value prop", () => {
|
||||
render(<ExportTypeSelector value="daily_with_models" onChange={vi.fn()} entityType="team" />);
|
||||
const modelRadio = screen.getByRole("radio", { name: /by team and model/i });
|
||||
expect(modelRadio).toBeChecked();
|
||||
it("should have the correct radio checked", () => {
|
||||
renderWithProviders(
|
||||
<ExportTypeSelector value="daily_with_models" onChange={vi.fn()} entityType="team" />
|
||||
);
|
||||
expect(screen.getByRole("radio", { name: /Day-by-day by team and model/i })).toBeChecked();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,34 +1,49 @@
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { renderWithProviders, screen } from "../../../tests/test-utils";
|
||||
import React from "react";
|
||||
import { MetricCard } from "./MetricCard";
|
||||
|
||||
describe("MetricCard", () => {
|
||||
it("should render", () => {
|
||||
render(<MetricCard label="Total Requests" value={1234} />);
|
||||
renderWithProviders(<MetricCard label="Total Requests" value={1234} />);
|
||||
expect(screen.getByText("Total Requests")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should display the numeric value", () => {
|
||||
render(<MetricCard label="Total Requests" value={1234} />);
|
||||
expect(screen.getByText("1234")).toBeInTheDocument();
|
||||
it("should display the label and value", () => {
|
||||
renderWithProviders(<MetricCard label="Success Rate" value="98.5%" />);
|
||||
expect(screen.getByText("Success Rate")).toBeInTheDocument();
|
||||
expect(screen.getByText("98.5%")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should display a string value", () => {
|
||||
render(<MetricCard label="Pass Rate" value="95.2%" />);
|
||||
expect(screen.getByText("95.2%")).toBeInTheDocument();
|
||||
it("should display numeric values", () => {
|
||||
renderWithProviders(<MetricCard label="Count" value={42} />);
|
||||
expect(screen.getByText("42")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should display subtitle when provided", () => {
|
||||
render(<MetricCard label="Blocked" value={42} subtitle="Last 7 days" />);
|
||||
expect(screen.getByText("Last 7 days")).toBeInTheDocument();
|
||||
it("should render icon when provided", () => {
|
||||
renderWithProviders(
|
||||
<MetricCard
|
||||
label="Metric"
|
||||
value={100}
|
||||
icon={<span data-testid="test-icon">icon</span>}
|
||||
/>
|
||||
);
|
||||
expect(screen.getByTestId("test-icon")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should not display subtitle when not provided", () => {
|
||||
render(<MetricCard label="Blocked" value={42} />);
|
||||
expect(screen.queryByText(/days/)).not.toBeInTheDocument();
|
||||
it("should not render icon container when no icon provided", () => {
|
||||
renderWithProviders(<MetricCard label="Metric" value={100} />);
|
||||
expect(screen.queryByTestId("test-icon")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should display icon when provided", () => {
|
||||
render(<MetricCard label="Status" value="OK" icon={<span data-testid="icon">!</span>} />);
|
||||
expect(screen.getByTestId("icon")).toBeInTheDocument();
|
||||
it("should render subtitle when provided", () => {
|
||||
renderWithProviders(
|
||||
<MetricCard label="Metric" value={100} subtitle="Last 24 hours" />
|
||||
);
|
||||
expect(screen.getByText("Last 24 hours")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should not render subtitle when not provided", () => {
|
||||
renderWithProviders(<MetricCard label="Metric" value={100} />);
|
||||
expect(screen.queryByText("Last 24 hours")).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -23,6 +23,11 @@ describe("HelpLink", () => {
|
||||
expect(screen.getByText("Custom docs link")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should have the correct href", () => {
|
||||
renderWithProviders(<HelpLink href="https://docs.example.com/test" />);
|
||||
expect(screen.getByRole("link")).toHaveAttribute("href", "https://docs.example.com/test");
|
||||
});
|
||||
|
||||
it("should include a screen-reader-only label for accessibility", () => {
|
||||
renderWithProviders(<HelpLink href="https://docs.example.com" />);
|
||||
|
||||
@@ -46,7 +51,21 @@ describe("HelpIcon", () => {
|
||||
expect(screen.getByText("Tooltip help text")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should hide tooltip content when not hovered", () => {
|
||||
renderWithProviders(<HelpIcon content="Hidden tooltip" />);
|
||||
expect(screen.queryByText("Hidden tooltip")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should show learn more link when learnMoreHref is provided", async () => {
|
||||
const user = userEvent.setup();
|
||||
renderWithProviders(
|
||||
<HelpIcon content="Help text" learnMoreHref="https://docs.example.com" />
|
||||
);
|
||||
await user.hover(screen.getByRole("button", { name: /help information/i }));
|
||||
expect(screen.getByText("Learn more")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should use custom learn more text when provided", async () => {
|
||||
const user = userEvent.setup();
|
||||
renderWithProviders(
|
||||
<HelpIcon
|
||||
@@ -84,6 +103,11 @@ describe("DocsMenu", () => {
|
||||
expect(screen.getByRole("button", { name: /docs/i })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should hide menu items initially", () => {
|
||||
renderWithProviders(<DocsMenu items={items} />);
|
||||
expect(screen.queryByText("Custom pricing")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should show menu items when button is clicked", async () => {
|
||||
const user = userEvent.setup();
|
||||
renderWithProviders(<DocsMenu items={items} />);
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
import { renderWithProviders, screen } from "../../../tests/test-utils";
|
||||
import { vi } from "vitest";
|
||||
import {
|
||||
PolicySelect,
|
||||
policyStyle,
|
||||
INPUT_POLICY_OPTIONS,
|
||||
OUTPUT_POLICY_OPTIONS,
|
||||
} from "./PolicySelect";
|
||||
|
||||
describe("policyStyle", () => {
|
||||
it("should return the matching option for a known policy", () => {
|
||||
expect(policyStyle("trusted")).toEqual(INPUT_POLICY_OPTIONS[1]);
|
||||
});
|
||||
|
||||
it("should return the matching option for blocked", () => {
|
||||
expect(policyStyle("blocked")).toEqual(INPUT_POLICY_OPTIONS[2]);
|
||||
});
|
||||
|
||||
it("should return the first option as fallback for unknown policy", () => {
|
||||
expect(policyStyle("unknown")).toEqual(INPUT_POLICY_OPTIONS[0]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("PolicySelect", () => {
|
||||
it("should render", () => {
|
||||
renderWithProviders(
|
||||
<PolicySelect
|
||||
value="untrusted"
|
||||
toolName="test-tool"
|
||||
saving={false}
|
||||
onChange={vi.fn()}
|
||||
/>
|
||||
);
|
||||
expect(screen.getByText("untrusted")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should show the current policy value", () => {
|
||||
renderWithProviders(
|
||||
<PolicySelect
|
||||
value="trusted"
|
||||
toolName="test-tool"
|
||||
saving={false}
|
||||
onChange={vi.fn()}
|
||||
/>
|
||||
);
|
||||
expect(screen.getByText("trusted")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should be disabled when saving is true", () => {
|
||||
renderWithProviders(
|
||||
<PolicySelect
|
||||
value="untrusted"
|
||||
toolName="test-tool"
|
||||
saving={true}
|
||||
onChange={vi.fn()}
|
||||
/>
|
||||
);
|
||||
expect(screen.getByRole("combobox")).toHaveAttribute("aria-expanded", "false");
|
||||
expect(screen.getByRole("combobox").closest(".ant-select")).toHaveClass("ant-select-disabled");
|
||||
});
|
||||
|
||||
it("should not be disabled when saving is false", () => {
|
||||
renderWithProviders(
|
||||
<PolicySelect
|
||||
value="untrusted"
|
||||
toolName="test-tool"
|
||||
saving={false}
|
||||
onChange={vi.fn()}
|
||||
/>
|
||||
);
|
||||
expect(screen.getByRole("combobox").closest(".ant-select")).not.toHaveClass("ant-select-disabled");
|
||||
});
|
||||
});
|
||||
|
||||
describe("Policy option constants", () => {
|
||||
it("should have 3 input policy options", () => {
|
||||
expect(INPUT_POLICY_OPTIONS).toHaveLength(3);
|
||||
});
|
||||
|
||||
it("should have 2 output policy options (no blocked)", () => {
|
||||
expect(OUTPUT_POLICY_OPTIONS).toHaveLength(2);
|
||||
expect(OUTPUT_POLICY_OPTIONS.map((o) => o.value)).toEqual(["untrusted", "trusted"]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,82 @@
|
||||
import { renderWithProviders, screen } from "../../../tests/test-utils";
|
||||
import { vi } from "vitest";
|
||||
import ComplexityRouterConfig from "./ComplexityRouterConfig";
|
||||
|
||||
const mockModelInfo = [
|
||||
{ model_group: "gpt-4" },
|
||||
{ model_group: "gpt-3.5-turbo" },
|
||||
{ model_group: "claude-3-opus" },
|
||||
] as any[];
|
||||
|
||||
const defaultTiers = {
|
||||
SIMPLE: "gpt-3.5-turbo",
|
||||
MEDIUM: "gpt-3.5-turbo",
|
||||
COMPLEX: "gpt-4",
|
||||
REASONING: "claude-3-opus",
|
||||
};
|
||||
|
||||
describe("ComplexityRouterConfig", () => {
|
||||
it("should render", () => {
|
||||
renderWithProviders(
|
||||
<ComplexityRouterConfig
|
||||
modelInfo={mockModelInfo}
|
||||
value={defaultTiers}
|
||||
onChange={vi.fn()}
|
||||
/>
|
||||
);
|
||||
expect(screen.getByText("Complexity Tier Configuration")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should display all four tier labels", () => {
|
||||
renderWithProviders(
|
||||
<ComplexityRouterConfig
|
||||
modelInfo={mockModelInfo}
|
||||
value={defaultTiers}
|
||||
onChange={vi.fn()}
|
||||
/>
|
||||
);
|
||||
expect(screen.getByText("Simple Tier")).toBeInTheDocument();
|
||||
expect(screen.getByText("Medium Tier")).toBeInTheDocument();
|
||||
expect(screen.getByText("Complex Tier")).toBeInTheDocument();
|
||||
expect(screen.getByText("Reasoning Tier")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should show example queries for each tier", () => {
|
||||
renderWithProviders(
|
||||
<ComplexityRouterConfig
|
||||
modelInfo={mockModelInfo}
|
||||
value={defaultTiers}
|
||||
onChange={vi.fn()}
|
||||
/>
|
||||
);
|
||||
expect(screen.getByText(/Hello!/)).toBeInTheDocument();
|
||||
expect(screen.getByText(/Explain how REST APIs work/)).toBeInTheDocument();
|
||||
expect(screen.getByText(/Design a microservices architecture/)).toBeInTheDocument();
|
||||
expect(screen.getByText(/Think step by step/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should display the how classification works section", () => {
|
||||
renderWithProviders(
|
||||
<ComplexityRouterConfig
|
||||
modelInfo={mockModelInfo}
|
||||
value={defaultTiers}
|
||||
onChange={vi.fn()}
|
||||
/>
|
||||
);
|
||||
expect(screen.getByText("How Classification Works")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should show score thresholds in the classification section", () => {
|
||||
renderWithProviders(
|
||||
<ComplexityRouterConfig
|
||||
modelInfo={mockModelInfo}
|
||||
value={defaultTiers}
|
||||
onChange={vi.fn()}
|
||||
/>
|
||||
);
|
||||
expect(screen.getByText(/Score < 0.15/)).toBeInTheDocument();
|
||||
expect(screen.getByText(/Score 0.15 - 0.35/)).toBeInTheDocument();
|
||||
expect(screen.getByText(/Score 0.35 - 0.60/)).toBeInTheDocument();
|
||||
expect(screen.getByText(/Score > 0.60/)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,90 @@
|
||||
import { renderWithProviders, screen } from "../../../tests/test-utils";
|
||||
import { vi } from "vitest";
|
||||
import AgentCardGrid from "./agent_card_grid";
|
||||
import type { Agent, AgentKeyInfo } from "./types";
|
||||
|
||||
vi.mock("./agent_card", () => ({
|
||||
default: ({ agent, onAgentClick }: any) => (
|
||||
<div data-testid={`agent-card-${agent.agent_id}`} onClick={() => onAgentClick(agent.agent_id)}>
|
||||
{agent.agent_name}
|
||||
</div>
|
||||
),
|
||||
}));
|
||||
|
||||
const mockAgents: Agent[] = [
|
||||
{
|
||||
agent_id: "agent-1",
|
||||
agent_name: "Test Agent 1",
|
||||
litellm_params: { model: "gpt-4" },
|
||||
agent_card_params: { description: "First agent" },
|
||||
},
|
||||
{
|
||||
agent_id: "agent-2",
|
||||
agent_name: "Test Agent 2",
|
||||
litellm_params: { model: "claude-3" },
|
||||
agent_card_params: { description: "Second agent" },
|
||||
},
|
||||
];
|
||||
|
||||
const mockKeyInfoMap: Record<string, AgentKeyInfo> = {
|
||||
"agent-1": { has_key: true, key_alias: "key-1" },
|
||||
"agent-2": { has_key: false },
|
||||
};
|
||||
|
||||
const defaultProps = {
|
||||
agentsList: mockAgents,
|
||||
keyInfoMap: mockKeyInfoMap,
|
||||
isLoading: false,
|
||||
onDeleteClick: vi.fn(),
|
||||
accessToken: "test-token",
|
||||
onAgentUpdated: vi.fn(),
|
||||
isAdmin: true,
|
||||
onAgentClick: vi.fn(),
|
||||
};
|
||||
|
||||
describe("AgentCardGrid", () => {
|
||||
it("should render", () => {
|
||||
renderWithProviders(<AgentCardGrid {...defaultProps} />);
|
||||
expect(screen.getByText("Test Agent 1")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should render all agent cards", () => {
|
||||
renderWithProviders(<AgentCardGrid {...defaultProps} />);
|
||||
expect(screen.getByText("Test Agent 1")).toBeInTheDocument();
|
||||
expect(screen.getByText("Test Agent 2")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should show loading skeletons when isLoading is true", () => {
|
||||
renderWithProviders(<AgentCardGrid {...defaultProps} isLoading={true} />);
|
||||
expect(screen.queryByText("Test Agent 1")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should show admin empty state message when no agents and isAdmin", () => {
|
||||
renderWithProviders(
|
||||
<AgentCardGrid {...defaultProps} agentsList={[]} isAdmin={true} />
|
||||
);
|
||||
expect(
|
||||
screen.getByText("No agents found. Create one to get started.")
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should show non-admin empty state message when no agents and not admin", () => {
|
||||
renderWithProviders(
|
||||
<AgentCardGrid {...defaultProps} agentsList={[]} isAdmin={false} />
|
||||
);
|
||||
expect(
|
||||
screen.getByText("No agents found. Contact an admin to create agents.")
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should call onAgentClick when a card is clicked", async () => {
|
||||
const onAgentClick = vi.fn();
|
||||
renderWithProviders(
|
||||
<AgentCardGrid {...defaultProps} onAgentClick={onAgentClick} />
|
||||
);
|
||||
const { default: userEvent } = await import("@testing-library/user-event");
|
||||
const user = userEvent.setup();
|
||||
await user.click(screen.getByTestId("agent-card-agent-1"));
|
||||
expect(onAgentClick).toHaveBeenCalledWith("agent-1");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,61 @@
|
||||
import { renderWithProviders, screen } from "../../../tests/test-utils";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { vi } from "vitest";
|
||||
import { Form } from "antd";
|
||||
import React from "react";
|
||||
import { RateLimitTypeFormItem } from "./RateLimitTypeFormItem";
|
||||
|
||||
const Wrapper: React.FC<{ children: React.ReactNode }> = ({ children }) => (
|
||||
<Form>{children}</Form>
|
||||
);
|
||||
|
||||
describe("RateLimitTypeFormItem", () => {
|
||||
it("should render", () => {
|
||||
renderWithProviders(
|
||||
<Wrapper>
|
||||
<RateLimitTypeFormItem type="tpm" name="tpm_type" />
|
||||
</Wrapper>
|
||||
);
|
||||
expect(screen.getByText(/TPM Rate Limit Type/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should display TPM label for tpm type", () => {
|
||||
renderWithProviders(
|
||||
<Wrapper>
|
||||
<RateLimitTypeFormItem type="tpm" name="tpm_type" />
|
||||
</Wrapper>
|
||||
);
|
||||
expect(screen.getByText(/TPM Rate Limit Type/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should display RPM label for rpm type", () => {
|
||||
renderWithProviders(
|
||||
<Wrapper>
|
||||
<RateLimitTypeFormItem type="rpm" name="rpm_type" />
|
||||
</Wrapper>
|
||||
);
|
||||
expect(screen.getByText(/RPM Rate Limit Type/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should show the select placeholder by default", () => {
|
||||
renderWithProviders(
|
||||
<Wrapper>
|
||||
<RateLimitTypeFormItem type="tpm" name="tpm_type" />
|
||||
</Wrapper>
|
||||
);
|
||||
expect(screen.getByText("Select rate limit type")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should call onChange when provided", async () => {
|
||||
const user = userEvent.setup();
|
||||
const onChange = vi.fn();
|
||||
renderWithProviders(
|
||||
<Wrapper>
|
||||
<RateLimitTypeFormItem type="tpm" name="tpm_type" onChange={onChange} />
|
||||
</Wrapper>
|
||||
);
|
||||
await user.click(screen.getByRole("combobox"));
|
||||
await user.click(screen.getByText("Guaranteed throughput"));
|
||||
expect(onChange).toHaveBeenCalledWith("guaranteed_throughput");
|
||||
});
|
||||
});
|
||||
@@ -60,6 +60,7 @@ import CodeInterpreterOutput from "./CodeInterpreterOutput";
|
||||
import CodeInterpreterTool from "./CodeInterpreterTool";
|
||||
import { generateCodeSnippet } from "./CodeSnippets";
|
||||
import EndpointSelector from "./EndpointSelector";
|
||||
import FilePreviewCard from "./FilePreviewCard";
|
||||
import MCPEventsDisplay from "./MCPEventsDisplay";
|
||||
import type { MCPEvent } from "../../mcp_tools/types";
|
||||
import { EndpointType, getEndpointType } from "./mode_endpoint_mapping";
|
||||
@@ -2231,67 +2232,19 @@ const ChatUI: React.FC<ChatUIProps> = ({
|
||||
|
||||
{/* Show file previews above input when files are uploaded */}
|
||||
{endpointType === EndpointType.RESPONSES && responsesUploadedImage && (
|
||||
<div className="mb-2">
|
||||
<div className="flex items-center gap-3 p-3 bg-gray-50 rounded-lg border border-gray-200">
|
||||
<div className="relative inline-block">
|
||||
{responsesUploadedImage.name.toLowerCase().endsWith(".pdf") ? (
|
||||
<div className="w-10 h-10 rounded-md bg-red-500 flex items-center justify-center">
|
||||
<FilePdfOutlined style={{ fontSize: "16px", color: "white" }} />
|
||||
</div>
|
||||
) : (
|
||||
<img
|
||||
src={responsesImagePreviewUrl || ""}
|
||||
alt="Upload preview"
|
||||
className="w-10 h-10 rounded-md border border-gray-200 object-cover"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="text-sm font-medium text-gray-900 truncate">{responsesUploadedImage.name}</div>
|
||||
<div className="text-xs text-gray-500">
|
||||
{responsesUploadedImage.name.toLowerCase().endsWith(".pdf") ? "PDF" : "Image"}
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
className="flex items-center justify-center w-6 h-6 text-gray-400 hover:text-gray-600 hover:bg-gray-200 rounded-full transition-colors"
|
||||
onClick={handleRemoveResponsesImage}
|
||||
>
|
||||
<DeleteOutlined style={{ fontSize: "12px" }} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<FilePreviewCard
|
||||
file={responsesUploadedImage}
|
||||
previewUrl={responsesImagePreviewUrl}
|
||||
onRemove={handleRemoveResponsesImage}
|
||||
/>
|
||||
)}
|
||||
|
||||
{endpointType === EndpointType.CHAT && chatUploadedImage && (
|
||||
<div className="mb-2">
|
||||
<div className="flex items-center gap-3 p-3 bg-gray-50 rounded-lg border border-gray-200">
|
||||
<div className="relative inline-block">
|
||||
{chatUploadedImage.name.toLowerCase().endsWith(".pdf") ? (
|
||||
<div className="w-10 h-10 rounded-md bg-red-500 flex items-center justify-center">
|
||||
<FilePdfOutlined style={{ fontSize: "16px", color: "white" }} />
|
||||
</div>
|
||||
) : (
|
||||
<img
|
||||
src={chatImagePreviewUrl || ""}
|
||||
alt="Upload preview"
|
||||
className="w-10 h-10 rounded-md border border-gray-200 object-cover"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="text-sm font-medium text-gray-900 truncate">{chatUploadedImage.name}</div>
|
||||
<div className="text-xs text-gray-500">
|
||||
{chatUploadedImage.name.toLowerCase().endsWith(".pdf") ? "PDF" : "Image"}
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
className="flex items-center justify-center w-6 h-6 text-gray-400 hover:text-gray-600 hover:bg-gray-200 rounded-full transition-colors"
|
||||
onClick={handleRemoveChatImage}
|
||||
>
|
||||
<DeleteOutlined style={{ fontSize: "12px" }} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<FilePreviewCard
|
||||
file={chatUploadedImage}
|
||||
previewUrl={chatImagePreviewUrl}
|
||||
onRemove={handleRemoveChatImage}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Code Interpreter indicator and sample prompts when enabled */}
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import FilePreviewCard from "./FilePreviewCard";
|
||||
|
||||
function makeFile(name: string): File {
|
||||
return new File(["dummy"], name, { type: "application/octet-stream" });
|
||||
}
|
||||
|
||||
describe("FilePreviewCard", () => {
|
||||
it("should render", () => {
|
||||
render(
|
||||
<FilePreviewCard file={makeFile("photo.png")} previewUrl={null} onRemove={vi.fn()} />
|
||||
);
|
||||
expect(screen.getByText("photo.png")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should display the file name", () => {
|
||||
render(
|
||||
<FilePreviewCard file={makeFile("my-screenshot.jpg")} previewUrl={null} onRemove={vi.fn()} />
|
||||
);
|
||||
expect(screen.getByText("my-screenshot.jpg")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should show 'Image' label for non-PDF files", () => {
|
||||
render(
|
||||
<FilePreviewCard file={makeFile("photo.png")} previewUrl="blob:http://localhost/abc" onRemove={vi.fn()} />
|
||||
);
|
||||
expect(screen.getByText("Image")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should show 'PDF' label for PDF files", () => {
|
||||
render(
|
||||
<FilePreviewCard file={makeFile("report.pdf")} previewUrl={null} onRemove={vi.fn()} />
|
||||
);
|
||||
expect(screen.getByText("PDF")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should render an image preview when the file is not a PDF", () => {
|
||||
render(
|
||||
<FilePreviewCard file={makeFile("photo.png")} previewUrl="blob:http://localhost/abc" onRemove={vi.fn()} />
|
||||
);
|
||||
expect(screen.getByAltText("Upload preview")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should not render an image preview when the file is a PDF", () => {
|
||||
render(
|
||||
<FilePreviewCard file={makeFile("doc.PDF")} previewUrl={null} onRemove={vi.fn()} />
|
||||
);
|
||||
expect(screen.queryByAltText("Upload preview")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should call onRemove when the remove button is clicked", async () => {
|
||||
const onRemove = vi.fn();
|
||||
const user = userEvent.setup();
|
||||
render(
|
||||
<FilePreviewCard file={makeFile("photo.png")} previewUrl={null} onRemove={onRemove} />
|
||||
);
|
||||
await user.click(screen.getByRole("button"));
|
||||
expect(onRemove).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("should treat .PDF (uppercase) as a PDF file", () => {
|
||||
render(
|
||||
<FilePreviewCard file={makeFile("REPORT.PDF")} previewUrl={null} onRemove={vi.fn()} />
|
||||
);
|
||||
expect(screen.getByText("PDF")).toBeInTheDocument();
|
||||
expect(screen.queryByAltText("Upload preview")).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,45 @@
|
||||
import { DeleteOutlined, FilePdfOutlined } from "@ant-design/icons";
|
||||
|
||||
interface FilePreviewCardProps {
|
||||
file: File;
|
||||
previewUrl: string | null;
|
||||
onRemove: () => void;
|
||||
}
|
||||
|
||||
function FilePreviewCard({ file, previewUrl, onRemove }: FilePreviewCardProps) {
|
||||
const isPdf = file.name.toLowerCase().endsWith(".pdf");
|
||||
|
||||
return (
|
||||
<div className="mb-2">
|
||||
<div className="flex items-center gap-3 p-3 bg-gray-50 rounded-lg border border-gray-200">
|
||||
<div className="relative inline-block">
|
||||
{isPdf ? (
|
||||
<div className="w-10 h-10 rounded-md bg-red-500 flex items-center justify-center">
|
||||
<FilePdfOutlined style={{ fontSize: "16px", color: "white" }} />
|
||||
</div>
|
||||
) : (
|
||||
<img
|
||||
src={previewUrl || ""}
|
||||
alt="Upload preview"
|
||||
className="w-10 h-10 rounded-md border border-gray-200 object-cover"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="text-sm font-medium text-gray-900 truncate">{file.name}</div>
|
||||
<div className="text-xs text-gray-500">
|
||||
{isPdf ? "PDF" : "Image"}
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
className="flex items-center justify-center w-6 h-6 text-gray-400 hover:text-gray-600 hover:bg-gray-200 rounded-full transition-colors"
|
||||
onClick={onRemove}
|
||||
>
|
||||
<DeleteOutlined style={{ fontSize: "12px" }} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default FilePreviewCard;
|
||||
Reference in New Issue
Block a user