Merge remote-tracking branch 'origin/litellm_internal_staging' into litellm_add_api_key_detail_page

# Conflicts:
#	ui/litellm-dashboard/eslint-metrics.json
This commit is contained in:
ryan-crabbe-berri 2026-07-09 20:48:07 -07:00
commit 115694dc0e
140 changed files with 4007 additions and 521 deletions

View file

@ -715,6 +715,7 @@ openai_compatible_endpoints: List = [
"https://api.clarifai.com/v2/ext/openai/v1",
"https://api.libertai.io/v1",
"https://pinstripes.io/v1",
"https://api.meta.ai/v1",
]
@ -781,6 +782,7 @@ openai_compatible_providers: List = [
"ragflow",
"pinstripes", # Pinstripes - JSON-configured provider
"darkbloom",
"meta", # Meta Model API (Muse Spark) - JSON-configured provider
]
openai_text_completion_compatible_providers: List = [ # providers that support `/v1/completions`
"together_ai",

View file

@ -757,6 +757,12 @@ class CustomGuardrail(CustomLogger):
# raw provider JSON so redaction is not duplicated upstream).
clean_guardrail_response = redact_nested_match_and_regex_keys(clean_guardrail_response)
from litellm.litellm_core_utils.sensitive_data_masker import (
mask_credentials_in_payload,
)
clean_guardrail_response = mask_credentials_in_payload(clean_guardrail_response)
slg = StandardLoggingGuardrailInformation(
guardrail_name=self.guardrail_name,
guardrail_provider=guardrail_provider,

View file

@ -346,6 +346,9 @@ def get_llm_provider(
elif endpoint == "https://pinstripes.io/v1":
custom_llm_provider = "pinstripes"
dynamic_api_key = get_secret_str("PINSTRIPES_API_KEY")
elif endpoint == "https://api.meta.ai/v1":
custom_llm_provider = "meta"
dynamic_api_key = get_secret_str("META_API_KEY")
if api_base is not None and not isinstance(api_base, str):
raise Exception("api base needs to be a string. api_base={}".format(api_base))

View file

@ -1,6 +1,8 @@
from collections.abc import Mapping
from typing import Any, Dict, List, Optional, Set
from pydantic import BaseModel
from litellm.constants import DEFAULT_MAX_RECURSE_DEPTH_SENSITIVE_DATA_MASKER
@ -153,6 +155,39 @@ def mask_sensitive_structure(data: object) -> object:
return _error_masker.mask(data)
def mask_credentials_in_payload(data: object) -> object:
"""Return a copy of ``data`` where string values under sensitive-named keys
are masked but every other value (``None``, ``int``, ``float``, ``bool``,
``bytes``, ``datetime``, tuples, sets, typed objects) is preserved by
identity, and dicts/lists are rebuilt structurally.
Use this for logging payloads that carry response data through to
SpendLogs / OTel / Langfuse, where :meth:`SensitiveDataMasker.mask`'s
config-dump semantics (``None`` -> ``"None"``, tuples stringified,
objects flattened via ``__dict__``) would silently distort the record.
Sensitive-key detection is delegated to the shared
:class:`SensitiveDataMasker` so pattern updates stay in one place.
"""
return _walk_payload(data, key_is_sensitive=False, depth=0)
def _walk_payload(node: object, key_is_sensitive: bool, depth: int) -> object:
if depth >= DEFAULT_MAX_RECURSE_DEPTH_SENSITIVE_DATA_MASKER:
return node
if isinstance(node, Mapping):
return {k: _walk_payload(v, _default_masker.is_sensitive_key(k), depth + 1) for k, v in node.items()}
if isinstance(node, list):
return [_walk_payload(item, key_is_sensitive, depth + 1) for item in node]
if isinstance(node, tuple):
return tuple(_walk_payload(item, key_is_sensitive, depth + 1) for item in node)
if isinstance(node, BaseModel):
return _walk_payload(node.model_dump(), key_is_sensitive, depth)
if key_is_sensitive and isinstance(node, str) and node:
return _default_masker._mask_value(node)
return node
def mask_sensitive_keys(data: Dict[str, Any], sensitive_fields: Set[str]) -> Dict[str, Any]:
"""Return a new dict with values masked for keys listed in ``sensitive_fields``.

View file

@ -91,7 +91,7 @@ def create_config_class(provider: SimpleProviderConfig):
def get_supported_openai_params(self, model: str) -> list:
"""Get supported OpenAI params, excluding tool-related params for models
that don't support function calling."""
from litellm.utils import supports_function_calling
from litellm.utils import supports_function_calling, supports_reasoning
supported_params = super().get_supported_openai_params(model=model)
@ -113,6 +113,10 @@ def create_config_class(provider: SimpleProviderConfig):
f"function calling — removed tool-related params from supported params."
)
_supports_reasoning = supports_reasoning(model=model, custom_llm_provider=provider.slug)
if _supports_reasoning and "reasoning_effort" not in supported_params:
supported_params.append("reasoning_effort")
return supported_params
def map_openai_params(

View file

@ -1,8 +1,11 @@
from typing import Any, Optional
import litellm
from litellm.llms.anthropic.experimental_pass_through.messages.transformation import (
AnthropicMessagesConfig,
)
from litellm.llms.openai_like.json_loader import SimpleProviderConfig
from litellm.secret_managers.main import get_secret_str
DEFAULT_ANTHROPIC_API_VERSION = "2023-06-01"
@ -67,3 +70,65 @@ class OpenAILikeAnthropicMessagesConfig(AnthropicMessagesConfig):
if base.endswith("/v1"):
base = base[: -len("/v1")]
return f"{base}/v1/messages"
class JSONProviderAnthropicMessagesConfig(OpenAILikeAnthropicMessagesConfig):
"""
Provider-level native Anthropic Messages passthrough for JSON-configured
OpenAI-compatible providers whose ``supported_endpoints`` in providers.json
includes ``"/v1/messages"``. Resolves the api key and api base from the
provider's configured env vars, then forwards the Anthropic payload
untranslated like ``OpenAILikeAnthropicMessagesConfig``.
"""
def __init__(self, provider: SimpleProviderConfig):
super().__init__()
self._provider = provider
def should_strip_billing_metadata(self) -> bool:
return True
def _resolve_api_key(self, api_key: Optional[str]) -> Optional[str]:
return api_key or get_secret_str(self._provider.api_key_env) or litellm.api_key
def _resolve_api_base(self, api_base: Optional[str]) -> str:
env_api_base = get_secret_str(self._provider.api_base_env) if self._provider.api_base_env else None
return api_base or env_api_base or self._provider.base_url
def validate_anthropic_messages_environment(
self,
headers: dict[str, str],
model: str,
messages: list[Any],
optional_params: dict,
litellm_params: dict,
api_key: Optional[str] = None,
api_base: Optional[str] = None,
) -> tuple[dict[str, str], Optional[str]]:
return super().validate_anthropic_messages_environment(
headers=headers,
model=model,
messages=messages,
optional_params=optional_params,
litellm_params=litellm_params,
api_key=self._resolve_api_key(api_key),
api_base=api_base,
)
def get_complete_url(
self,
api_base: Optional[str],
api_key: Optional[str],
model: str,
optional_params: dict,
litellm_params: dict,
stream: Optional[bool] = None,
) -> str:
return super().get_complete_url(
api_base=self._resolve_api_base(api_base),
api_key=api_key,
model=model,
optional_params=optional_params,
litellm_params=litellm_params,
stream=stream,
)

View file

@ -168,6 +168,13 @@
},
"supported_endpoints": ["/v1/chat/completions", "/v1/responses"]
},
"meta": {
"base_url": "https://api.meta.ai/v1",
"api_key_env": "META_API_KEY",
"api_base_env": "META_API_BASE",
"base_class": "openai_gpt",
"supported_endpoints": ["/v1/chat/completions", "/v1/responses", "/v1/messages"]
},
"pinstripes": {
"base_url": "https://pinstripes.io/v1",
"api_key_env": "PINSTRIPES_API_KEY",

View file

@ -25501,6 +25501,42 @@
"supports_function_calling": true,
"supports_tool_choice": false
},
"meta/muse-spark-1.1": {
"cache_read_input_token_cost": 1.5e-07,
"input_cost_per_token": 1.25e-06,
"litellm_provider": "meta",
"max_input_tokens": 1048576,
"max_output_tokens": 131072,
"max_tokens": 131072,
"mode": "chat",
"output_cost_per_token": 4.25e-06,
"source": "https://dev.meta.ai/docs/getting-started/pricing-rate-limits",
"supported_endpoints": [
"/v1/chat/completions",
"/v1/responses",
"/v1/messages"
],
"supported_modalities": [
"text",
"image",
"video"
],
"supported_output_modalities": [
"text"
],
"supports_function_calling": true,
"supports_minimal_reasoning_effort": true,
"supports_parallel_function_calling": true,
"supports_pdf_input": true,
"supports_prompt_caching": true,
"supports_reasoning": true,
"supports_response_schema": true,
"supports_system_messages": true,
"supports_tool_choice": true,
"supports_vision": true,
"supports_web_search": true,
"supports_xhigh_reasoning_effort": true
},
"meta_llama/Llama-3.3-70B-Instruct": {
"litellm_provider": "meta_llama",
"max_input_tokens": 128000,

View file

@ -3,7 +3,7 @@ import binascii
import hashlib
import json
from datetime import datetime, timedelta, timezone
from typing import TYPE_CHECKING, Any, Dict, Iterable, List, Optional, Set, Union, cast
from typing import TYPE_CHECKING, Any, Awaitable, Callable, Dict, Iterable, List, Optional, Set, Union, cast
from litellm._logging import verbose_proxy_logger
from litellm._uuid import uuid
@ -558,7 +558,11 @@ async def delete_mcp_server_from_virtualkey():
pass
async def delete_mcp_server(prisma_client: PrismaClient, server_id: str) -> Optional[LiteLLM_MCPServerTable]:
async def delete_mcp_server(
prisma_client: PrismaClient,
server_id: str,
invalidate_token_cache: Optional[Callable[[str, str], Awaitable[None]]] = None,
) -> Optional[LiteLLM_MCPServerTable]:
"""
Delete the mcp server from the db by server_id
@ -569,6 +573,12 @@ async def delete_mcp_server(prisma_client: PrismaClient, server_id: str) -> Opti
caller-visible error. Each table is cleaned independently so a failure on one
still attempts the other.
Each enumerated credential row's user also gets their cached per-user token
invalidated (legacy cache + v2 store, via invalidate_token_cache, defaulting
to the manager's shared invalidation): the caches are keyed by
(user_id, server_id), so without this a re-created server reusing the same
server_id would serve tokens minted for the deleted server until TTL.
Returns the deleted mcp server record if it exists, otherwise None
"""
deleted_server = await MCPServerRepository(prisma_client).table.delete(
@ -577,6 +587,18 @@ async def delete_mcp_server(prisma_client: PrismaClient, server_id: str) -> Opti
},
)
if deleted_server is not None:
credential_user_ids: List[str] = []
try:
credential_rows = await prisma_client.db.litellm_mcpusercredentials.find_many(
where={"server_id": server_id}
)
credential_user_ids = [row.user_id for row in credential_rows]
except Exception as e: # noqa: BLE001 - enumeration is best-effort; cached tokens expire by TTL
verbose_proxy_logger.warning(
"MCP server %s deleted but per-user credential enumeration failed; cached tokens expire by TTL: %s",
server_id,
e,
)
for model, label in (
(prisma_client.db.litellm_mcpusercredentials, "credential"),
(prisma_client.db.litellm_mcpuserenvvars, "env var"),
@ -591,6 +613,15 @@ async def delete_mcp_server(prisma_client: PrismaClient, server_id: str) -> Opti
label,
e,
)
if credential_user_ids:
if invalidate_token_cache is None:
from litellm.proxy._experimental.mcp_server.mcp_server_manager import (
global_mcp_server_manager,
)
invalidate_token_cache = global_mcp_server_manager.invalidate_user_oauth_token_cache
for user_id in credential_user_ids:
await invalidate_token_cache(user_id, server_id)
return deleted_server
@ -1070,6 +1101,103 @@ async def list_user_oauth_credentials(
return results
def _decrypted_credential_field(creds: Dict[str, object], field: str) -> object:
"""Return one credential field decrypted with the global salt key; non-string and legacy
plaintext values come back unchanged (decrypt_value_helper returns the original on failure)."""
value = creds.get(field)
if not isinstance(value, str):
return value
return decrypt_value_helper(
value=value,
key=field,
exception_type="debug",
return_original_value=True,
)
def mcp_oauth_token_identity(server: object) -> tuple[object, ...]:
"""The upstream-OAuth-token-determining fields of an MCP server: the resource/audience (url, or
spec_path for OpenAPI servers), the OAuth mode/grant (auth_type, oauth2_flow), the
authorization-server endpoints, and the OAuth client + scopes. Mirrors the dashboard's
getOAuthAuthorizationIdentity. When any of these change on a server update, previously stored
per-user tokens were minted for the old identity and are stale. Excludes transport and
delegate_auth_to_upstream, which do not affect what token is minted (RFC 8707/8693).
client_id/client_secret are compared decrypted: stored values are NaCl-encrypted with a fresh
nonce on every write, so comparing ciphertext would flag every routine save as an identity
change and purge tokens that are still valid."""
creds = getattr(server, "credentials", None)
if isinstance(creds, str):
try:
parsed: object = json.loads(creds)
except ValueError:
parsed = None
else:
parsed = creds
creds_dict: Dict[str, object] = parsed if isinstance(parsed, dict) else {}
return (
getattr(server, "url", None),
getattr(server, "spec_path", None),
getattr(server, "auth_type", None),
getattr(server, "oauth2_flow", None),
getattr(server, "authorization_url", None),
getattr(server, "token_url", None),
getattr(server, "registration_url", None),
_decrypted_credential_field(creds_dict, "client_id"),
_decrypted_credential_field(creds_dict, "client_secret"),
creds_dict.get("scopes"),
)
async def purge_user_oauth_credentials_for_server(
prisma_client: PrismaClient,
server_id: str,
invalidate_token_cache: Optional[Callable[[str, str], Awaitable[None]]] = None,
) -> int:
"""Delete every stored per-user OAuth token for a server and invalidate each user's cached
token everywhere it can be served from (the legacy per-user token cache and the v2 per-user OAuth
token store), so no user keeps a token minted for a superseded configuration. Called when a server
update changes a mint-relevant field (see mcp_oauth_token_identity). Returns the number of rows
removed.
LiteLLM_MCPUserCredentials also stores BYOK API keys in the same column; only rows whose payload
decodes as an OAuth2 credential (see _decode_oauth_payload) are deleted, because a config change
only invalidates minted tokens, never a user's own stored key. Rows are therefore deleted per
(user_id, server_id) pair rather than by a blanket server_id filter. An OAuth row inserted while
the purge runs for a user not yet enumerated survives; a re-auth completing in the window for an
already-enumerated user is deleted along with the stale row (the pair delete cannot tell them
apart), which costs that user one extra re-auth and nothing else.
invalidate_token_cache is injectable for tests; it defaults to the manager's shared
invalidate_user_oauth_token_cache, the single invalidation point for per-user tokens."""
repo = MCPUserCredentialsRepository(prisma_client)
rows = await repo.table.find_many(where={"server_id": server_id})
oauth_rows = [row for row in rows if _decode_oauth_payload(row.credential_b64) is not None]
if not oauth_rows:
return 0
deleted_count = await repo.table.delete_many(
where={"server_id": server_id, "user_id": {"in": [row.user_id for row in oauth_rows]}}
)
if invalidate_token_cache is None:
from litellm.proxy._experimental.mcp_server.mcp_server_manager import (
global_mcp_server_manager,
)
invalidate_token_cache = global_mcp_server_manager.invalidate_user_oauth_token_cache
for row in oauth_rows:
await invalidate_token_cache(row.user_id, server_id)
if deleted_count != len(oauth_rows):
verbose_proxy_logger.warning(
"MCP server %s: purge removed %d OAuth credential row(s) but %d were enumerated; "
"row(s) were deleted concurrently during the purge",
server_id,
deleted_count,
len(oauth_rows),
)
return deleted_count
async def refresh_user_oauth_token(
prisma_client: PrismaClient,
user_id: str,

View file

@ -57,7 +57,11 @@ from litellm.proxy._experimental.mcp_server.elicitation_handler import (
from litellm.proxy._experimental.mcp_server.sampling_handler import (
MCP_SAMPLING_AVAILABLE,
)
from litellm.proxy._experimental.mcp_server.oauth2_token_cache import resolve_mcp_auth
from litellm.proxy._experimental.mcp_server.oauth2_token_cache import (
MCPPerUserTokenCache,
mcp_per_user_token_cache,
resolve_mcp_auth,
)
from litellm.proxy._experimental.mcp_server.outbound_credentials import (
Error,
Ok,
@ -799,10 +803,12 @@ class MCPServerManager:
self,
cred_provider: Optional[UpstreamCredentialProvider] = None,
per_user_oauth_token_store: Optional[InvalidatableOAuthTokenStore] = None,
per_user_token_cache: Optional[MCPPerUserTokenCache] = None,
):
self._per_user_oauth_token_store = per_user_oauth_token_store or LazyPerUserOAuthTokenStore(
self.get_mcp_server_by_id
)
self._per_user_token_cache = per_user_token_cache or mcp_per_user_token_cache
self._cred_provider = cred_provider or UpstreamCredentialProvider(
oauth_token_store=self._per_user_oauth_token_store,
token_exchanger=build_token_exchanger(),
@ -4053,10 +4059,13 @@ class MCPServerManager:
return await self._cred_provider.has_user_token(to_subject(user_api_key_auth, None), spec)
async def invalidate_user_oauth_token_cache(self, user_id: str, server_id: str) -> None:
"""Drop the v2 chain's cached token for ``(user_id, server_id)`` after the credential row
changes (re-auth, revoke), so the next resolve reads the new row instead of serving the
replaced token until its cache TTL. Best-effort: a cache-drop failure is logged, never
raised, because the DB write already succeeded and the TTL remains the backstop.
"""Drop every cached token for ``(user_id, server_id)`` after the credential row changes
(re-auth, revoke, config-change purge): the v2 chain's cache and the legacy per-user token
cache, so the next resolve reads the new row instead of serving the replaced token until its
cache TTL, whichever path resolves it. This is the single invalidation point for per-user
OAuth tokens; callers must not evict individual caches directly. Best-effort: a cache-drop
failure is logged, never raised, because the DB write already succeeded and the TTL remains
the backstop.
"""
try:
await self._per_user_oauth_token_store.invalidate(user_id, server_id)
@ -4064,6 +4073,12 @@ class MCPServerManager:
verbose_logger.warning(
"Failed to invalidate cached MCP OAuth token for user=%s server=%s: %s", user_id, server_id, exc
)
try:
await self._per_user_token_cache.delete(user_id, server_id)
except Exception as exc: # noqa: BLE001 - cache drop is best-effort; TTL is the backstop
verbose_logger.warning(
"Failed to drop legacy cached MCP OAuth token for user=%s server=%s: %s", user_id, server_id, exc
)
async def _resolve_oauth2_headers_for_tool_call(
self,

View file

@ -98,7 +98,10 @@ from litellm.repositories.user_repository import UserRepository
from litellm.router import Router
from litellm.utils import get_utc_datetime
from .auth_checks_organization import organization_role_based_access_check
from .auth_checks_organization import (
add_team_org_context_to_request_body,
organization_role_based_access_check,
)
from .auth_utils import get_model_from_request
if TYPE_CHECKING:
@ -707,10 +710,28 @@ async def common_checks(
# 10 [OPTIONAL] Organization RBAC checks
organization_role_based_access_check(user_object=user_object, route=route, request_body=request_body)
async def _fetch_team_org_id(team_id: str) -> Optional[str]:
try:
team = await get_team_object(
team_id=team_id,
prisma_client=prisma_client,
user_api_key_cache=user_api_key_cache,
proxy_logging_obj=proxy_logging_obj,
)
except HTTPException:
return None
return team.organization_id
request_body_for_route_check = await add_team_org_context_to_request_body(
route=route,
request_body=request_body,
fetch_team_org_id=_fetch_team_org_id,
)
_is_route_allowed = _is_api_route_allowed(
route=route,
request=request,
request_data=request_body,
request_data=request_body_for_route_check,
valid_token=valid_token,
user_obj=user_object,
)

View file

@ -2,7 +2,7 @@
Auth Checks for Organizations
"""
from typing import Dict, List, Optional, Tuple
from typing import Awaitable, Callable, Dict, List, Optional, Tuple
from fastapi import status
@ -170,3 +170,33 @@ def _user_is_org_admin(
# User must be admin of ALL requested orgs, not just any one
return all(org_id in admin_org_ids for org_id in candidate_org_ids)
TEAM_ORG_CONTEXT_ROUTES = frozenset({"/team/update"})
async def add_team_org_context_to_request_body(
route: str,
request_body: dict,
fetch_team_org_id: Callable[[str], Awaitable[Optional[str]]],
) -> dict:
"""
Return a copy of request_body with organization_id resolved from the target
team when the route identifies the team by team_id and the caller did not
pass organization_id. This lets an org admin of the team's own org reach the
org-scoped branch of the route gate (which keys off organization_id) without
the client having to send it. Returns request_body unchanged when it does
not apply, so callers that already pass organization_id and non-team routes
are untouched.
"""
if route not in TEAM_ORG_CONTEXT_ROUTES:
return request_body
if request_body.get("organization_id"):
return request_body
team_id = request_body.get("team_id")
if not isinstance(team_id, str) or not team_id:
return request_body
org_id = await fetch_team_org_id(team_id)
if not org_id:
return request_body
return {**request_body, "organization_id": org_id}

View file

@ -125,7 +125,9 @@ if MCP_AVAILABLE:
get_user_env_vars_bulk,
get_user_oauth_credential,
list_user_oauth_credentials,
mcp_oauth_token_identity,
merge_user_env_vars,
purge_user_oauth_credentials_for_server,
reject_mcp_server,
store_user_credential,
store_user_oauth_credential,
@ -2318,6 +2320,19 @@ if MCP_AVAILABLE:
},
)
# Snapshot the pre-update identity so we can detect a mint-relevant change below. The read is
# advisory (it only feeds the stale-token purge decision), so a failure skips the purge with a
# warning instead of failing the edit, whose primary job is the update itself.
try:
old_server_record = await get_mcp_server(prisma_client, payload.server_id)
except Exception as exc: # noqa: BLE001 - advisory read; invalidation is best-effort end-to-end
verbose_logger.warning(
"MCP server %s: could not snapshot the pre-update record; skipping the stale-token check: %s",
payload.server_id,
exc,
)
old_server_record = None
# try to update the mcp server
mcp_server_record_updated = await update_mcp_server(
prisma_client,
@ -2336,6 +2351,30 @@ if MCP_AVAILABLE:
# Ensure registry is up to date by reloading from database
await global_mcp_server_manager.reload_servers_from_database()
# If a field that determines which upstream OAuth token gets minted changed (url/audience, OAuth
# mode/grant, authorization-server endpoints, or the OAuth client + scopes), every stored per-user
# token was minted for the old configuration and is stale. Purge them (DB + cache) so the next
# tool call re-authorizes instead of forwarding a token for a resource/AS/client that no longer
# matches. Best-effort: a purge failure must not fail the update, whose primary job already
# succeeded.
if old_server_record is not None and mcp_oauth_token_identity(old_server_record) != mcp_oauth_token_identity(
mcp_server_record_updated
):
try:
purged = await purge_user_oauth_credentials_for_server(prisma_client, payload.server_id)
if purged:
verbose_logger.info(
"MCP server %s: purged %d stale per-user OAuth token(s) after a mint-relevant config change",
payload.server_id,
purged,
)
except Exception as exc: # noqa: BLE001 - purge is best-effort; the server update already succeeded
verbose_logger.warning(
"MCP server %s: failed to purge stale per-user OAuth tokens after config change: %s",
payload.server_id,
exc,
)
# TODO: Enterprise: Finish audit log trail
if litellm.store_audit_logs:
pass

View file

@ -2533,9 +2533,10 @@ class StandardLoggingMCPToolCall(TypedDict, total=False):
mcp_server_resource: Optional[str]
"""
The upstream MCP server resource identifier (scheme + host + path) the tool call was
forwarded to. Redacted for logging: userinfo, query string, and fragment are stripped so an
upstream URL carrying an embedded token or secret query parameter never reaches log metadata.
The origin (scheme + host + port) of the upstream MCP server the tool call was forwarded
to. Redacted for logging: userinfo, the path, the query string, and the fragment are all
stripped, because hosted MCP servers routinely embed the credential in the URL path and
this value is readable by callers via request logs.
Records which upstream received a relayed request; never a credential.
"""
@ -3397,6 +3398,7 @@ class LlmProviders(str, Enum):
LIBERTAI = "libertai"
PINSTRIPES = "pinstripes"
DARKBLOOM = "darkbloom"
META = "meta"
LITELLM_AGENT = "litellm_agent"
CURSOR = "cursor"
BEDROCK_MANTLE = "bedrock_mantle"

View file

@ -8028,6 +8028,16 @@ class ProviderConfigManager:
)
return GithubCopilotAnthropicMessagesConfig()
from litellm.llms.openai_like.json_loader import JSONProviderRegistry
json_provider = JSONProviderRegistry.get(provider.value)
if json_provider is not None and "/v1/messages" in json_provider.supported_endpoints:
from litellm.llms.openai_like.messages.transformation import (
JSONProviderAnthropicMessagesConfig,
)
return JSONProviderAnthropicMessagesConfig(json_provider)
return None
@staticmethod

View file

@ -25659,6 +25659,42 @@
"supports_function_calling": true,
"supports_tool_choice": false
},
"meta/muse-spark-1.1": {
"cache_read_input_token_cost": 1.5e-07,
"input_cost_per_token": 1.25e-06,
"litellm_provider": "meta",
"max_input_tokens": 1048576,
"max_output_tokens": 131072,
"max_tokens": 131072,
"mode": "chat",
"output_cost_per_token": 4.25e-06,
"source": "https://dev.meta.ai/docs/getting-started/pricing-rate-limits",
"supported_endpoints": [
"/v1/chat/completions",
"/v1/responses",
"/v1/messages"
],
"supported_modalities": [
"text",
"image",
"video"
],
"supported_output_modalities": [
"text"
],
"supports_function_calling": true,
"supports_minimal_reasoning_effort": true,
"supports_parallel_function_calling": true,
"supports_pdf_input": true,
"supports_prompt_caching": true,
"supports_reasoning": true,
"supports_response_schema": true,
"supports_system_messages": true,
"supports_tool_choice": true,
"supports_vision": true,
"supports_web_search": true,
"supports_xhigh_reasoning_effort": true
},
"meta_llama/Llama-3.3-70B-Instruct": {
"litellm_provider": "meta_llama",
"max_input_tokens": 128000,

View file

@ -1984,6 +1984,23 @@
"interactions": true
}
},
"meta": {
"display_name": "Meta Model API (`meta`)",
"url": "https://docs.litellm.ai/docs/providers/meta",
"endpoints": {
"chat_completions": true,
"messages": true,
"responses": true,
"embeddings": false,
"image_generations": false,
"audio_transcriptions": false,
"audio_speech": false,
"moderations": false,
"batches": false,
"rerank": false,
"a2a": false
}
},
"pinstripes": {
"display_name": "Pinstripes (`pinstripes`)",
"url": "https://docs.litellm.ai/docs/providers/pinstripes",

View file

@ -36,6 +36,7 @@ IGNORE_FUNCTIONS = [
"_collect_argument_paths", # max depth set.
"_split_text", # max depth set.
"_mask_sequence", # max depth set.
"_walk_payload", # max depth set (DEFAULT_MAX_RECURSE_DEPTH_SENSITIVE_DATA_MASKER).
"_delete_nested_value_custom", # max depth set (bounded by number of path segments).
"filter_exceptions_from_params", # max depth set (default 20) to prevent infinite recursion.
"__getattr__", # lazy loading pattern in litellm/__init__.py with proper caching to prevent infinite recursion.

View file

@ -44,10 +44,10 @@ class Provider:
)
case "azure":
return LiteLLMParamsBody(
model="azure/gpt-4.1-mini-batch",
model="azure/gpt-5.4-mini-batch",
api_base="os.environ/AZURE_API_BASE",
api_key="os.environ/AZURE_API_KEY",
api_version="2024-07-01-preview",
api_version="2025-04-01-preview",
)
case "vertex_ai":
return LiteLLMParamsBody(
@ -97,7 +97,7 @@ class Capability:
PROVIDERS: tuple[Provider, ...] = (
Provider("openai", "openai-batch", "gpt-4o-mini", can_cancel=True, can_list=True),
Provider("azure", "azure-batch", "gpt-4.1-mini-batch", can_cancel=True, can_list=True),
Provider("azure", "azure-batch", "gpt-5.4-mini-batch", can_cancel=True, can_list=True),
Provider(
"vertex_ai", "vertex-batch", "gemini-2.5-flash", can_cancel=True, can_list=True
),

View file

@ -43,7 +43,7 @@ at call time. The provider table below is the source of truth; edit `PROVIDERS`
| gemini | `gemini-realtime` | `gemini/gemini-3.1-flash-live-preview` |
| vertex_ai | `vertex-realtime` | `vertex_ai/gemini-live-2.5-flash-preview-native-audio-09-2025` |
Bedrock and xai (`xai/grok-4-1-fast-non-reasoning`) are supported by the proxy but
Bedrock and xai (`xai/grok-4-1-fast`) are supported by the proxy but
kept commented out in `PROVIDERS` until they pass end-to-end here; re-enable them by
uncommenting their entry.

View file

@ -95,7 +95,7 @@ PROVIDERS = (
# "xai",
# "xai-realtime",
# LiteLLMParamsBody(
# model="xai/grok-4-1-fast-non-reasoning",
# model="xai/grok-4-1-fast",
# api_key="os.environ/XAI_API_KEY",
# ),
# ), # TODO: Enable once xai Grok Voice realtime is passing end-to-end here

View file

@ -96,7 +96,7 @@ SERVER_VAD_SETTINGS = rt_events.SessionProperties(
noise_reduction=rt_events.InputAudioNoiseReduction(type="near_field"),
turn_detection=rt_events.TurnDetection(
type="server_vad",
threshold=0.8,
threshold=0.5,
prefix_padding_ms=300,
silence_duration_ms=700,
),

View file

@ -0,0 +1,163 @@
"""Live e2e: provider-specific /chat/completions features take real effect.
Each case asserts the feature actually happened, not just a 200. Coverage matrix
(register-on-demand deployments, deleted on teardown):
- Bedrock (anthropic claude-haiku-4-5): prompt caching. A large cacheable prefix
marked with ``cache_control`` is sent twice; the second call must report
cache-read usage tokens > 0. service_tier is out of scope for Bedrock; AWS
Bedrock does not expose an OpenAI-style request service tier, so that cell is
intentionally not covered here.
- Vertex (gemini-2.5-flash): prompt caching via ``cache_control`` context
caching; the second identical call must report cached prompt tokens > 0.
service_tier lives in test_provider_features_e2e.py.
The provider-native cache_control request shape is not expressible with the
shared ``ChatBody`` (whose content is a plain string), so the cacheable body is
modelled locally with typed content blocks.
"""
from __future__ import annotations
import time
import pytest
from pydantic import BaseModel
from e2e_config import unique_marker
from e2e_http import Result, unwrap
from lifecycle import ResourceManager
from models import ChatResponse, LiteLLMParamsBody, Usage
from passthrough_client import PassthroughClient
import os
pytestmark = pytest.mark.e2e
BEDROCK_MODEL = "bedrock/us.anthropic.claude-haiku-4-5-20251001-v1:0"
VERTEX_MODEL = "vertex_ai/gemini-2.5-flash"
class CacheControl(BaseModel):
type: str = "ephemeral"
class TextBlock(BaseModel):
type: str = "text"
text: str
cache_control: CacheControl | None = None
class RichMessage(BaseModel):
role: str
content: list[TextBlock]
class CacheChatBody(BaseModel):
model: str
messages: list[RichMessage]
max_tokens: int = 64
cache: dict[str, bool] = {"no-cache": True}
def _cacheable_prefix() -> str:
"""A prefix long enough to clear provider minimum cacheable sizes (Haiku is
2048 tokens), unique per run so the first call writes and the second reads."""
marker = unique_marker()
body = " ".join(
f"Cacheable reference paragraph {index} for run {marker}." for index in range(600)
)
return f"{body}\nEnd of reference material {marker}."
def _cached_read_tokens(usage: Usage | None) -> int:
"""Cache-read tokens however the provider reports them: Anthropic-style
``cache_read_input_tokens`` or OpenAI-style ``prompt_tokens_details.cached_tokens``."""
if usage is None:
return 0
if usage.cache_read_input_tokens:
return usage.cache_read_input_tokens
if usage.prompt_tokens_details and usage.prompt_tokens_details.cached_tokens:
return usage.prompt_tokens_details.cached_tokens
return 0
def _cache_chat(
client: PassthroughClient, key: str, model: str, prefix: str
) -> Result[ChatResponse]:
body = CacheChatBody(
model=model,
messages=[
RichMessage(
role="system",
content=[TextBlock(text=prefix, cache_control=CacheControl())],
),
RichMessage(role="user", content=[TextBlock(text="Reply with one word.")]),
],
)
return client.gateway.transport.post(
"/chat/completions",
headers=client.gateway.transport.bearer(key),
json=body,
response_type=ChatResponse,
)
def _assert_cache_read_on_second_call(
client: PassthroughClient, key: str, model: str
) -> None:
prefix = _cacheable_prefix()
first = unwrap(_cache_chat(client, key, model, prefix))
assert first.choices, f"{model}: first cache-priming call returned no choices: {first}"
read_tokens = 0
deadline = time.monotonic() + 30.0
while time.monotonic() < deadline:
second = unwrap(_cache_chat(client, key, model, prefix))
read_tokens = _cached_read_tokens(second.usage)
if read_tokens > 0:
break
time.sleep(3.0)
assert read_tokens > 0, (
f"{model}: second identical call reported no cache-read tokens "
f"({second.usage}); prompt caching did not take effect"
)
class TestCacheControl:
@pytest.mark.covers(
"llm.chat_completions.bedrock_converse.prompt_cache_5m.nonstream.works",
exercised_on=[],
)
def test_bedrock_prompt_caching_reads_cache(
self, client: PassthroughClient, resources: ResourceManager
) -> None:
model = f"e2e-bedrock-cache-{unique_marker()}"
model_id = client.gateway.create_model(
model,
LiteLLMParamsBody(model=BEDROCK_MODEL, aws_region_name="us-east-1"),
)
resources.defer(lambda: client.gateway.delete_model(model_id))
_assert_cache_read_on_second_call(client, resources.key(), model)
@pytest.mark.covers(
"llm.chat_completions.vertex.prompt_cache_5m.nonstream.works",
exercised_on=[],
)
def test_vertex_prompt_caching_reads_cache(
self, client: PassthroughClient, resources: ResourceManager
) -> None:
model = f"e2e-vertex-cache-{unique_marker()}"
model_id = client.gateway.create_model(
model,
LiteLLMParamsBody(
model=VERTEX_MODEL,
vertex_project=os.environ.get("VERTEXAI_PROJECT"),
vertex_location="us-central1",
vertex_credentials=os.environ.get("VERTEXAI_CREDENTIALS"),
),
)
resources.defer(lambda: client.gateway.delete_model(model_id))
_assert_cache_read_on_second_call(client, resources.key(), model)

View file

@ -1,4 +1,4 @@
"""Live e2e for model-specific request features: service_tier and prompt caching.
"""Live e2e for model-specific request features: service_tier.
Each case asserts the feature took effect, not just a 200.
@ -11,81 +11,22 @@ avoided here because it is capacity-constrained and returns a transient 429 when
flex resources are unavailable. Bedrock and Vertex do not accept service_tier, so
that cell is OpenAI-only by design.
Prompt caching is asserted through provider prompt-cache usage tokens. The
deterministic path is explicit ``cache_control`` on an Anthropic-family model
(here Bedrock's Claude): a large cacheable prefix is sent twice and the second
call must report ``cache_read_input_tokens > 0``. OpenAI and Gemini only offer
implicit automatic caching, which does not deterministically produce a cache read
within a test window (verified: repeated >3k-token prompts kept
``prompt_tokens_details.cached_tokens`` at 0), so those caching cells are out of
scope here and covered only by the explicit-cache-control Bedrock case.
Prompt caching lives in test_cache_control.py.
"""
from __future__ import annotations
import pytest
from pydantic import BaseModel, ConfigDict, Field
from e2e_config import unique_marker
from e2e_http import unwrap
from lifecycle import ResourceManager
from models import ChatBody, ChatMessage, ChatResponse, LiteLLMParamsBody
from models import ChatBody, ChatMessage, LiteLLMParamsBody
from passthrough_client import PassthroughClient
pytestmark = pytest.mark.e2e
SERVICE_TIER = "priority"
CACHE_MIN_READ_TOKENS = 1
class CacheControl(BaseModel):
type: str = "ephemeral"
class CacheTextBlock(BaseModel):
type: str = "text"
text: str
cache_control: CacheControl | None = None
class RichMessage(BaseModel):
role: str
content: list[CacheTextBlock]
class CacheDirective(BaseModel):
"""litellm per-request cache control. ``no-cache`` forces the proxy to skip its
own response cache and make a fresh provider call, so the second identical
request actually reaches Bedrock and reads the provider prompt cache instead of
being served the first response verbatim (which would report cache_read=0)."""
model_config = ConfigDict(populate_by_name=True)
no_cache: bool = Field(default=True, alias="no-cache")
class CacheChatBody(BaseModel):
model: str
messages: list[RichMessage]
max_tokens: int
cache: CacheDirective = CacheDirective()
def cacheable_prefix() -> str:
return (
"You are a policy compliance auditor. The following corpus is the immutable "
"reference the assistant must consult on every turn. "
) + ("Clause: obey all safety, formatting, and citation rules exactly. " * 400)
def post_chat(client: PassthroughClient, key: str, body: BaseModel) -> ChatResponse:
return unwrap(
client.gateway.transport.post(
"/chat/completions",
headers=client.gateway.transport.bearer(key),
json=body,
response_type=ChatResponse,
)
)
class TestServiceTier:
@ -120,50 +61,3 @@ class TestServiceTier:
f"service_tier not honored: sent {SERVICE_TIER!r}, response reported "
f"{response.service_tier!r} ({response})"
)
class TestPromptCaching:
@pytest.mark.covers(
"llm.chat_completions.bedrock_converse.prompt_cache_5m.nonstream.works",
exercised_on=[],
)
def test_bedrock_cache_control_produces_cache_read(
self, client: PassthroughClient, resources: ResourceManager
) -> None:
model = f"e2e-bedrock-cache-{unique_marker()}"
model_id = client.gateway.create_model(
model,
LiteLLMParamsBody(
model="bedrock/us.anthropic.claude-haiku-4-5-20251001-v1:0",
aws_region_name="us-east-1",
),
)
resources.defer(lambda: client.gateway.delete_model(model_id))
key = resources.key()
body = CacheChatBody(
model=model,
max_tokens=32,
messages=[
RichMessage(
role="user",
content=[
CacheTextBlock(
text=cacheable_prefix(), cache_control=CacheControl()
),
CacheTextBlock(text="Answer in one word: acknowledged?"),
],
)
],
)
first = post_chat(client, key, body)
assert first.usage is not None, f"first call reported no usage: {first}"
second = post_chat(client, key, body)
assert second.usage is not None, f"second call reported no usage: {second}"
cache_read = second.usage.cache_read_input_tokens
assert cache_read is not None and cache_read >= CACHE_MIN_READ_TOKENS, (
"second identical request did not read the prompt cache: "
f"cache_read_input_tokens={cache_read!r} (usage={second.usage})"
)

View file

@ -105,27 +105,35 @@ async def test_team_update_authz_matrix(
assert row.team_alias != MARKER_ALIAS, "denied but team mutated"
async def test_team_update_requires_proxy_admin_without_org_context(
async def test_team_update_org_admin_resolved_from_team_without_org_context(
proxy_client, prisma, scratch, world
):
"""With no organization_id in the body the route gate has no org context
and falls back to proxy-admin-only: an org admin of the team's own org
is 401, PROXY_ADMIN is 200."""
"""With no organization_id in the body the route gate resolves the target
team's org from team_id, so an org admin of the team's own org is allowed
(200), same as PROXY_ADMIN. A team admin of that same team stays denied
(401): the resolution grants org admins access, not team admins."""
await _seed_target(prisma, world, "alpha", scratch.prefix)
denied = await proxy_client.post(
allowed_org_admin = await proxy_client.post(
"/team/update",
headers={"Authorization": f"Bearer {world.keys[Actor.ORG_ADMIN].cleartext}"},
json={"team_id": scratch.prefix, "team_alias": MARKER_ALIAS},
)
assert denied.status_code == 401, denied.text
assert allowed_org_admin.status_code == 200, allowed_org_admin.text
allowed = await proxy_client.post(
allowed_proxy_admin = await proxy_client.post(
"/team/update",
headers={"Authorization": f"Bearer {world.keys[Actor.PROXY_ADMIN].cleartext}"},
json={"team_id": scratch.prefix, "team_alias": MARKER_ALIAS},
)
assert allowed.status_code == 200, allowed.text
assert allowed_proxy_admin.status_code == 200, allowed_proxy_admin.text
denied_team_admin = await proxy_client.post(
"/team/update",
headers={"Authorization": f"Bearer {world.keys[Actor.TEAM_ADMIN].cleartext}"},
json={"team_id": scratch.prefix, "team_alias": MARKER_ALIAS},
)
assert denied_team_admin.status_code == 401, denied_team_admin.text
# Relocation gate — moving a team to a different org. The scratch team starts

View file

@ -833,6 +833,173 @@ class TestGuardrailSensitiveFieldStripping:
assert "sk-secret" not in serialized
class TestGuardrailResponseCredentialMasking:
"""LIT-4314 issue B regression: credentials embedded in guardrail_response
(via team callback_vars flowing through data["metadata"]) must be masked at
the construction seam so every downstream sink (SpendLogs, OTel, Langfuse,
custom loggers) sees masked values rather than plaintext.
"""
def _make_guardrail(self):
from litellm.types.guardrails import GuardrailEventHooks
return CustomGuardrail(
guardrail_name="test_guardrail",
event_hook=GuardrailEventHooks.pre_call,
)
def test_callback_vars_api_key_is_masked(self):
import json
guardrail = self._make_guardrail()
request_data: dict = {"metadata": {}}
plaintext_key = "lsv2_pt_abcdef1234567890"
guardrail.add_standard_logging_guardrail_information_to_request_data(
guardrail_json_response={
"model": "gpt-4o-mini",
"messages": [{"role": "user", "content": "hi"}],
"metadata_snapshot": {
"callback_vars": {
"langsmith_api_key": plaintext_key,
"langsmith_project": "proj-name",
}
},
},
request_data=request_data,
guardrail_status="success",
duration=1.0,
)
logged = request_data["metadata"]["standard_logging_guardrail_information"][0][
"guardrail_response"
]
masked_key = logged["metadata_snapshot"]["callback_vars"]["langsmith_api_key"]
assert masked_key != plaintext_key
assert "*" in masked_key
assert plaintext_key not in json.dumps(request_data)
assert logged["model"] == "gpt-4o-mini"
assert logged["messages"] == [{"role": "user", "content": "hi"}]
assert (
logged["metadata_snapshot"]["callback_vars"]["langsmith_project"]
== "proj-name"
)
def test_nested_user_api_key_auth_metadata_is_masked(self):
import json
guardrail = self._make_guardrail()
request_data: dict = {"metadata": {}}
token_value = "1b01552f6e52e0d41963dd6a185bd6b074624e330999534ca7ff5adfdf622dfc"
guardrail.add_standard_logging_guardrail_information_to_request_data(
guardrail_json_response={
"evaluated_metadata": {
"user_api_key_auth": {
"token": token_value,
"api_key": token_value,
"metadata": {
"callback_vars": {
"langsmith_api_key": "lsv2_pt_super_secret_value_1234",
}
},
}
}
},
request_data=request_data,
guardrail_status="success",
)
serialized = json.dumps(request_data)
assert token_value not in serialized
assert "lsv2_pt_super_secret_value_1234" not in serialized
def test_secret_fields_pop_still_runs(self):
import json
guardrail = self._make_guardrail()
request_data: dict = {"metadata": {}}
guardrail.add_standard_logging_guardrail_information_to_request_data(
guardrail_json_response={
"model": "gpt-4",
"secret_fields": {
"raw_headers": {
"authorization": "Bearer sk-live-should-not-appear",
}
},
},
request_data=request_data,
guardrail_status="success",
)
serialized = json.dumps(request_data)
assert "secret_fields" not in serialized
assert "sk-live-should-not-appear" not in serialized
def test_match_and_regex_redaction_still_runs(self):
guardrail = self._make_guardrail()
request_data: dict = {"metadata": {}}
guardrail.add_standard_logging_guardrail_information_to_request_data(
guardrail_json_response={
"filters": [{"regex": r"\d{3}-\d{2}-\d{4}", "action": "BLOCKED"}]
},
request_data=request_data,
guardrail_status="success",
)
slg = request_data["metadata"]["standard_logging_guardrail_information"][0]
assert slg["guardrail_response"]["filters"][0]["regex"] == "[REDACTED]"
def test_scalar_types_pass_through_unchanged(self):
guardrail = self._make_guardrail()
request_data: dict = {"metadata": {}}
guardrail.add_standard_logging_guardrail_information_to_request_data(
guardrail_json_response={
"flagged": True,
"score": 0.94,
"tokens_used": 42,
"categories": ["pii", "toxicity"],
},
request_data=request_data,
guardrail_status="success",
)
logged = request_data["metadata"]["standard_logging_guardrail_information"][0][
"guardrail_response"
]
assert logged["flagged"] is True
assert logged["score"] == 0.94
assert logged["tokens_used"] == 42
assert logged["categories"] == ["pii", "toxicity"]
def test_masking_reveals_prefix_and_suffix(self):
guardrail = self._make_guardrail()
request_data: dict = {"metadata": {}}
plaintext = "lsv2_pt_abcdef1234567890"
guardrail.add_standard_logging_guardrail_information_to_request_data(
guardrail_json_response={
"metadata_snapshot": {
"callback_vars": {"langsmith_api_key": plaintext}
}
},
request_data=request_data,
guardrail_status="success",
)
masked = request_data["metadata"]["standard_logging_guardrail_information"][0][
"guardrail_response"
]["metadata_snapshot"]["callback_vars"]["langsmith_api_key"]
assert masked != plaintext
assert masked.startswith(plaintext[:4])
assert masked.endswith(plaintext[-4:])
class TestCustomGuardrailPassthroughSupport:
"""Tests for passthrough endpoint guardrail support - Issue fixes."""

View file

@ -240,3 +240,78 @@ def test_mask_sensitive_structure_masks_credentials_nested_in_config_shape():
[{"primary-group": [{"model": "gpt-4o", "api_key": secret}]}]
)
assert secret not in str(masked)
def test_mask_credentials_in_payload_preserves_none_and_scalars():
"""The payload variant does not distort JSON-shaped values: None stays None,
ints/floats/bools stay themselves, lists stay lists. This is what makes it
safe for logging pipelines that persist the record verbatim."""
from litellm.litellm_core_utils.sensitive_data_masker import mask_credentials_in_payload
result = mask_credentials_in_payload(
{
"reason": None,
"confidence": 0.42,
"flagged": True,
"tokens_used": 17,
"categories": ["pii", "toxicity"],
"nested": {"end_user_id": None},
}
)
assert result == {
"reason": None,
"confidence": 0.42,
"flagged": True,
"tokens_used": 17,
"categories": ["pii", "toxicity"],
"nested": {"end_user_id": None},
}
def test_mask_credentials_in_payload_masks_inside_pydantic_models():
"""A Pydantic model reached during the walk gets dumped to a dict so its
sensitive-named string fields are masked. Without this the credentials
inside a nested ``UserAPIKeyAuth`` in a guardrail_response reach the
logging pipeline unmasked once JSON serialization flattens it."""
from pydantic import BaseModel
from litellm.litellm_core_utils.sensitive_data_masker import mask_credentials_in_payload
class Auth(BaseModel):
token: str = "1b01552f6e52e0d41963dd6a185bd6b074624e330999534ca7ff5adfdf622dfc"
team_alias: str = "acme"
result = mask_credentials_in_payload({"user_api_key_auth": Auth()})
auth_dict = result["user_api_key_auth"]
assert isinstance(auth_dict, dict)
assert auth_dict["team_alias"] == "acme"
assert (
auth_dict["token"]
!= "1b01552f6e52e0d41963dd6a185bd6b074624e330999534ca7ff5adfdf622dfc"
)
assert "*" in auth_dict["token"]
def test_mask_credentials_in_payload_masks_only_sensitive_string_leaves():
"""Sensitive-named string leaves get masked; sibling non-string values
(including None) under the same key stay verbatim."""
from litellm.litellm_core_utils.sensitive_data_masker import mask_credentials_in_payload
plaintext = "lsv2_pt_abcdef1234567890"
result = mask_credentials_in_payload(
{
"model": "gpt-4o-mini",
"callback_vars": {
"langsmith_api_key": plaintext,
"langsmith_project": "proj",
"extra_token_count": 5,
},
}
)
assert result["model"] == "gpt-4o-mini"
assert result["callback_vars"]["langsmith_project"] == "proj"
assert result["callback_vars"]["extra_token_count"] == 5
masked = result["callback_vars"]["langsmith_api_key"]
assert masked != plaintext
assert masked.startswith(plaintext[:4])
assert masked.endswith(plaintext[-4:])

View file

@ -0,0 +1,224 @@
"""
Tests for the Meta Model API (Muse Spark) provider configuration and integration.
"""
import litellm
class TestMetaProviderConfig:
def test_meta_in_provider_list(self):
from litellm import LlmProviders
assert hasattr(LlmProviders, "META")
assert LlmProviders.META.value == "meta"
assert "meta" in litellm.provider_list
def test_meta_json_config_exists(self):
from litellm.llms.openai_like.json_loader import JSONProviderRegistry
assert JSONProviderRegistry.exists("meta")
meta = JSONProviderRegistry.get("meta")
assert meta is not None
assert meta.base_url == "https://api.meta.ai/v1"
assert meta.api_key_env == "META_API_KEY"
assert meta.api_base_env == "META_API_BASE"
def test_meta_supports_responses_api(self):
from litellm.llms.openai_like.json_loader import JSONProviderRegistry
assert JSONProviderRegistry.supports_responses_api("meta")
def test_meta_in_openai_compatible_providers(self):
from litellm.constants import openai_compatible_providers
assert "meta" in openai_compatible_providers
def test_meta_provider_resolution(self):
from litellm.litellm_core_utils.get_llm_provider_logic import get_llm_provider
model, provider, api_key, api_base = get_llm_provider(
model="meta/muse-spark-1.1",
custom_llm_provider=None,
api_base=None,
api_key="sk-test",
)
assert model == "muse-spark-1.1"
assert provider == "meta"
assert api_base == "https://api.meta.ai/v1"
def test_meta_api_base_override(self):
from litellm.litellm_core_utils.get_llm_provider_logic import get_llm_provider
model, provider, api_key, api_base = get_llm_provider(
model="meta/muse-spark-1.1",
custom_llm_provider=None,
api_base="https://custom.meta.ai/v1",
api_key="sk-test",
)
assert provider == "meta"
assert api_base == "https://custom.meta.ai/v1"
assert api_key == "sk-test"
def test_meta_url_autodetection(self):
from litellm.litellm_core_utils.get_llm_provider_logic import get_llm_provider
model, provider, api_key, api_base = get_llm_provider(
model="muse-spark-1.1",
custom_llm_provider=None,
api_base="https://api.meta.ai/v1",
api_key=None,
)
assert provider == "meta"
assert api_base == "https://api.meta.ai/v1"
def test_meta_router_config(self):
from litellm import Router
router = Router(
model_list=[
{
"model_name": "muse-spark",
"litellm_params": {
"model": "meta/muse-spark-1.1",
"api_key": "test-key",
},
}
]
)
assert len(router.model_list) == 1
assert router.model_list[0]["model_name"] == "muse-spark"
class TestMetaReasoningParams:
def test_muse_spark_supports_reasoning_effort(self):
params = litellm.get_supported_openai_params(
model="muse-spark-1.1", custom_llm_provider="meta"
)
assert params is not None
assert "reasoning_effort" in params
def test_reasoning_effort_mapped_through(self):
cfg = litellm.ProviderConfigManager.get_provider_chat_config(
model="muse-spark-1.1", provider=litellm.LlmProviders.META
)
assert cfg is not None
mapped = cfg.map_openai_params(
non_default_params={"reasoning_effort": "xhigh"},
optional_params={},
model="muse-spark-1.1",
drop_params=False,
)
assert mapped["reasoning_effort"] == "xhigh"
def test_reasoning_effort_gated_on_capability(self):
"""A meta model without reasoning metadata must not advertise reasoning_effort."""
params = litellm.get_supported_openai_params(
model="some-non-reasoning-model", custom_llm_provider="meta"
)
assert params is not None
assert "reasoning_effort" not in params
class TestMetaAnthropicMessages:
def test_meta_resolves_native_messages_config(self):
from litellm.llms.openai_like.messages.transformation import (
JSONProviderAnthropicMessagesConfig,
)
cfg = litellm.ProviderConfigManager.get_provider_anthropic_messages_config(
model="muse-spark-1.1", provider=litellm.LlmProviders.META
)
assert isinstance(cfg, JSONProviderAnthropicMessagesConfig)
def test_json_provider_without_messages_endpoint_resolves_none(self):
cfg = litellm.ProviderConfigManager.get_provider_anthropic_messages_config(
model="some-model", provider=litellm.LlmProviders.PINSTRIPES
)
assert cfg is None
def test_complete_url_defaults_to_meta_base(self):
from litellm.llms.openai_like.json_loader import JSONProviderRegistry
from litellm.llms.openai_like.messages.transformation import (
JSONProviderAnthropicMessagesConfig,
)
provider = JSONProviderRegistry.get("meta")
assert provider is not None
cfg = JSONProviderAnthropicMessagesConfig(provider)
url = cfg.get_complete_url(
api_base=None,
api_key="sk-test",
model="muse-spark-1.1",
optional_params={},
litellm_params={},
)
assert url == "https://api.meta.ai/v1/messages"
override_url = cfg.get_complete_url(
api_base="https://custom.meta.ai/v1",
api_key="sk-test",
model="muse-spark-1.1",
optional_params={},
litellm_params={},
)
assert override_url == "https://custom.meta.ai/v1/messages"
def test_api_key_resolved_from_env(self, monkeypatch):
from litellm.llms.openai_like.json_loader import JSONProviderRegistry
from litellm.llms.openai_like.messages.transformation import (
JSONProviderAnthropicMessagesConfig,
)
monkeypatch.setenv("META_API_KEY", "sk-env-key")
provider = JSONProviderRegistry.get("meta")
assert provider is not None
cfg = JSONProviderAnthropicMessagesConfig(provider)
headers, _ = cfg.validate_anthropic_messages_environment(
headers={},
model="muse-spark-1.1",
messages=[{"role": "user", "content": "hi"}],
optional_params={},
litellm_params={},
api_key=None,
api_base=None,
)
assert headers["authorization"] == "Bearer sk-env-key"
assert headers["anthropic-version"] == "2023-06-01"
class TestMuseSparkModelInfo:
def test_muse_spark_pricing_and_capabilities(self):
info = litellm.get_model_info("meta/muse-spark-1.1")
assert info["litellm_provider"] == "meta"
assert info["input_cost_per_token"] == 1.25e-06
assert info["output_cost_per_token"] == 4.25e-06
assert info["cache_read_input_token_cost"] == 1.5e-07
assert info["max_input_tokens"] == 1048576
assert info["supports_reasoning"] is True
assert info["supports_web_search"] is True
assert info["supports_vision"] is True
assert info["supports_function_calling"] is True
assert info["supports_prompt_caching"] is True
def test_muse_spark_cost_calculation(self):
from litellm import completion_cost
from litellm.types.utils import ModelResponse, Usage
response = ModelResponse(
model="muse-spark-1.1",
usage=Usage(prompt_tokens=1000, completion_tokens=500, total_tokens=1500),
)
cost = completion_cost(
completion_response=response,
model="meta/muse-spark-1.1",
custom_llm_provider="meta",
)
expected = 1000 * 1.25e-06 + 500 * 4.25e-06
assert abs(cost - expected) < 1e-12

View file

@ -11,6 +11,7 @@ keeps a plain-base64 fallback on read so existing rows continue to work.
import base64
import json
from datetime import datetime, timedelta, timezone
from types import SimpleNamespace
from unittest.mock import AsyncMock, MagicMock
import pytest
@ -63,6 +64,264 @@ def _legacy_row(payload: str):
return row
def _identity_server(**overrides):
base = dict(
url="https://up.example.com/mcp",
auth_type="oauth2",
oauth2_flow="authorization_code",
authorization_url="https://idp.example.com/authorize",
token_url="https://idp.example.com/token",
registration_url="https://idp.example.com/register",
credentials={"client_id": "cid", "client_secret": "csec", "scopes": ["a"]},
server_name="srv",
description="d",
)
base.update(overrides)
return SimpleNamespace(**base)
@pytest.mark.parametrize(
"overrides",
[
{"url": "https://other.example.com/mcp"},
{"spec_path": "https://up.example.com/openapi.json"},
{"auth_type": "oauth_delegate"},
{"oauth2_flow": "client_credentials"},
{"authorization_url": "https://other.example.com/authorize"},
{"token_url": "https://other.example.com/token"},
{"registration_url": "https://other.example.com/register"},
{"credentials": {"client_id": "new", "client_secret": "csec", "scopes": ["a"]}},
{"credentials": {"client_id": "cid", "client_secret": "rotated", "scopes": ["a"]}},
{"credentials": {"client_id": "cid", "client_secret": "csec", "scopes": ["b"]}},
],
)
def test_mcp_oauth_token_identity_changes_on_mint_relevant_fields(overrides):
from litellm.proxy._experimental.mcp_server.db import mcp_oauth_token_identity
assert mcp_oauth_token_identity(_identity_server()) != mcp_oauth_token_identity(_identity_server(**overrides))
@pytest.mark.parametrize(
"overrides",
[
{"server_name": "renamed"},
{"description": "changed"},
],
)
def test_mcp_oauth_token_identity_stable_on_non_mint_fields(overrides):
from litellm.proxy._experimental.mcp_server.db import mcp_oauth_token_identity
assert mcp_oauth_token_identity(_identity_server()) == mcp_oauth_token_identity(_identity_server(**overrides))
def _encrypted_creds_json(client_id: str = "cid", client_secret: str = "csec") -> str:
from litellm.proxy._experimental.mcp_server.db import encrypt_credentials
encrypted = encrypt_credentials(
credentials={"client_id": client_id, "client_secret": client_secret, "scopes": ["a"]},
encryption_key=None,
)
return json.dumps(encrypted)
def test_mcp_oauth_token_identity_stable_across_reencryption():
"""Stored client_id/client_secret are NaCl-encrypted with a fresh nonce on every write, so two
saves of the SAME plaintext produce different ciphertext. The identity must compare decrypted
values; comparing ciphertext would flag every routine save as a mint-relevant change and purge
per-user tokens that are still valid."""
from litellm.proxy._experimental.mcp_server.db import mcp_oauth_token_identity
first = _encrypted_creds_json()
second = _encrypted_creds_json()
assert first != second
assert mcp_oauth_token_identity(_identity_server(credentials=first)) == mcp_oauth_token_identity(
_identity_server(credentials=second)
)
def test_mcp_oauth_token_identity_detects_change_under_encryption():
from litellm.proxy._experimental.mcp_server.db import mcp_oauth_token_identity
unchanged = _identity_server(credentials=_encrypted_creds_json())
changed = _identity_server(credentials=_encrypted_creds_json(client_id="other"))
assert mcp_oauth_token_identity(unchanged) != mcp_oauth_token_identity(changed)
def _oauth_row(user_id: str, server_id: str = "srv-1"):
"""A stored per-user OAuth token row (payload tagged type=oauth2, legacy plain-base64 encoding)."""
row = _legacy_row(json.dumps({"type": "oauth2", "access_token": "tok-" + user_id}))
row.user_id = user_id
row.server_id = server_id
return row
def _byok_row(user_id: str, server_id: str = "srv-1"):
"""A stored BYOK API key row: the same column, but the payload is a plain string, not OAuth JSON."""
row = _legacy_row("sk-byok-" + user_id)
row.user_id = user_id
row.server_id = server_id
return row
@pytest.mark.asyncio
async def test_purge_user_oauth_credentials_for_server_invalidates_each_user():
"""The purge must route each (user, server) row through the invalidator exactly once."""
from litellm.proxy._experimental.mcp_server.db import purge_user_oauth_credentials_for_server
prisma = MagicMock()
prisma.db.litellm_mcpusercredentials.find_many = AsyncMock(return_value=[_oauth_row("alice"), _oauth_row("bob")])
prisma.db.litellm_mcpusercredentials.delete_many = AsyncMock(return_value=2)
invalidations = []
async def record_invalidation(user_id: str, server_id: str) -> None:
invalidations.append((user_id, server_id))
purged = await purge_user_oauth_credentials_for_server(prisma, "srv-1", invalidate_token_cache=record_invalidation)
assert purged == 2
prisma.db.litellm_mcpusercredentials.delete_many.assert_awaited_once_with(
where={"server_id": "srv-1", "user_id": {"in": ["alice", "bob"]}}
)
assert set(invalidations) == {("alice", "srv-1"), ("bob", "srv-1")}
@pytest.mark.asyncio
async def test_purge_user_oauth_credentials_for_server_spares_byok_rows():
"""Regression: the purge used to delete_many on server_id alone, wiping BYOK API keys that share
the LiteLLM_MCPUserCredentials table. Only rows holding an OAuth2 payload may be deleted (one
batched query filtered to their user_ids), and only their users' token caches invalidated."""
from litellm.proxy._experimental.mcp_server.db import purge_user_oauth_credentials_for_server
prisma = MagicMock()
prisma.db.litellm_mcpusercredentials.find_many = AsyncMock(return_value=[_byok_row("carol"), _oauth_row("alice")])
prisma.db.litellm_mcpusercredentials.delete_many = AsyncMock(return_value=1)
invalidations = []
async def record_invalidation(user_id: str, server_id: str) -> None:
invalidations.append((user_id, server_id))
purged = await purge_user_oauth_credentials_for_server(prisma, "srv-1", invalidate_token_cache=record_invalidation)
assert purged == 1
prisma.db.litellm_mcpusercredentials.delete_many.assert_awaited_once_with(
where={"server_id": "srv-1", "user_id": {"in": ["alice"]}}
)
assert invalidations == [("alice", "srv-1")]
@pytest.mark.asyncio
async def test_purge_user_oauth_credentials_for_server_all_byok_is_noop():
"""An api_key (BYOK-only) server whose identity tuple changes (e.g. its url) must purge nothing."""
from litellm.proxy._experimental.mcp_server.db import purge_user_oauth_credentials_for_server
prisma = MagicMock()
prisma.db.litellm_mcpusercredentials.find_many = AsyncMock(return_value=[_byok_row("carol"), _byok_row("dave")])
prisma.db.litellm_mcpusercredentials.delete_many = AsyncMock()
purged = await purge_user_oauth_credentials_for_server(prisma, "srv-1")
assert purged == 0
prisma.db.litellm_mcpusercredentials.delete_many.assert_not_awaited()
@pytest.mark.asyncio
async def test_purge_user_oauth_credentials_for_server_defaults_to_manager_invalidator(monkeypatch):
"""When no invalidator is injected, the purge must resolve to the manager's shared
invalidate_user_oauth_token_cache, the single point covering both the legacy per-user token cache
and the v2 per-user OAuth token store; a wrong or no-op default silently leaves every cache
serving tokens minted for the superseded config."""
from litellm.proxy._experimental.mcp_server import mcp_server_manager
from litellm.proxy._experimental.mcp_server.db import purge_user_oauth_credentials_for_server
prisma = MagicMock()
prisma.db.litellm_mcpusercredentials.find_many = AsyncMock(return_value=[_oauth_row("alice")])
prisma.db.litellm_mcpusercredentials.delete_many = AsyncMock(return_value=1)
shared_invalidator = AsyncMock()
monkeypatch.setattr(
mcp_server_manager.global_mcp_server_manager,
"invalidate_user_oauth_token_cache",
shared_invalidator,
)
purged = await purge_user_oauth_credentials_for_server(prisma, "srv-1")
assert purged == 1
shared_invalidator.assert_awaited_once_with("alice", "srv-1")
@pytest.mark.asyncio
async def test_purge_user_oauth_credentials_for_server_logs_raced_rows(monkeypatch):
from litellm.proxy._experimental.mcp_server import db as db_module
from litellm.proxy._experimental.mcp_server.db import purge_user_oauth_credentials_for_server
prisma = MagicMock()
prisma.db.litellm_mcpusercredentials.find_many = AsyncMock(return_value=[_oauth_row("alice")])
prisma.db.litellm_mcpusercredentials.delete_many = AsyncMock(return_value=0)
warning = MagicMock()
monkeypatch.setattr(db_module.verbose_proxy_logger, "warning", warning)
purged = await purge_user_oauth_credentials_for_server(prisma, "srv-1", invalidate_token_cache=AsyncMock())
assert purged == 0
warning.assert_called_once()
@pytest.mark.asyncio
async def test_delete_mcp_server_invalidates_cached_tokens_for_enumerated_users():
"""Deleting a server must invalidate each enumerated user's cached per-user token: the caches are
keyed by (user_id, server_id), so a re-created server reusing the same server_id would otherwise
serve tokens minted for the deleted server until TTL."""
from litellm.proxy._experimental.mcp_server.db import delete_mcp_server
prisma = MagicMock()
prisma.db.litellm_mcpservertable.delete = AsyncMock(return_value=MagicMock(server_id="srv-1"))
prisma.db.litellm_mcpusercredentials.find_many = AsyncMock(return_value=[_oauth_row("alice"), _byok_row("bob")])
prisma.db.litellm_mcpusercredentials.delete_many = AsyncMock(return_value=2)
prisma.db.litellm_mcpuserenvvars.delete_many = AsyncMock(return_value=0)
invalidations = []
async def record_invalidation(user_id: str, server_id: str) -> None:
invalidations.append((user_id, server_id))
deleted = await delete_mcp_server(prisma, "srv-1", invalidate_token_cache=record_invalidation)
assert deleted is not None
assert set(invalidations) == {("alice", "srv-1"), ("bob", "srv-1")}
@pytest.mark.asyncio
async def test_delete_mcp_server_returns_none_without_cleanup_when_server_missing():
from litellm.proxy._experimental.mcp_server.db import delete_mcp_server
prisma = MagicMock()
prisma.db.litellm_mcpservertable.delete = AsyncMock(return_value=None)
prisma.db.litellm_mcpusercredentials.find_many = AsyncMock()
deleted = await delete_mcp_server(prisma, "srv-1", invalidate_token_cache=AsyncMock())
assert deleted is None
prisma.db.litellm_mcpusercredentials.find_many.assert_not_awaited()
@pytest.mark.asyncio
async def test_purge_user_oauth_credentials_for_server_noop_when_empty():
from litellm.proxy._experimental.mcp_server.db import purge_user_oauth_credentials_for_server
prisma = MagicMock()
prisma.db.litellm_mcpusercredentials.find_many = AsyncMock(return_value=[])
prisma.db.litellm_mcpusercredentials.delete_many = AsyncMock()
purged = await purge_user_oauth_credentials_for_server(prisma, "srv-1")
assert purged == 0
prisma.db.litellm_mcpusercredentials.delete_many.assert_not_awaited()
def _stored_value(prisma) -> str:
"""Pull the credential_b64 value passed to the most recent upsert call."""
call = prisma.db.litellm_mcpusercredentials.upsert.call_args
@ -136,9 +395,7 @@ async def test_store_user_oauth_credential_does_not_persist_plaintext():
access_token = "ya29.a0AfH6SMBverysecretaccesstoken"
prisma = _make_prisma_with_existing(row=None)
await store_user_oauth_credential(
prisma, "alice", "srv-1", access_token, refresh_token="rfr-xyz"
)
await store_user_oauth_credential(prisma, "alice", "srv-1", access_token, refresh_token="rfr-xyz")
stored = _stored_value(prisma)
try:
@ -221,9 +478,7 @@ async def test_byok_guard_rejects_overwriting_encrypted_byok():
encrypted_row = MagicMock()
encrypted_row.credential_b64 = _stored_value(prisma)
prisma.db.litellm_mcpusercredentials.find_unique = AsyncMock(
return_value=encrypted_row
)
prisma.db.litellm_mcpusercredentials.find_unique = AsyncMock(return_value=encrypted_row)
with pytest.raises(ValueError, match="could not be verified as an OAuth2"):
await store_user_oauth_credential(prisma, "alice", "srv-1", "tok")
@ -265,18 +520,14 @@ async def test_list_oauth_credentials_filters_byok_and_returns_payloads():
"connected_at": "2024-01-01T00:00:00Z",
}
legacy_row = MagicMock()
legacy_row.credential_b64 = base64.urlsafe_b64encode(
json.dumps(legacy_payload).encode()
).decode()
legacy_row.credential_b64 = base64.urlsafe_b64encode(json.dumps(legacy_payload).encode()).decode()
legacy_row.server_id = "srv-legacy"
byok_row = MagicMock()
byok_row.credential_b64 = base64.urlsafe_b64encode(b"plain-byok-key").decode()
byok_row.server_id = "srv-byok"
prisma.db.litellm_mcpusercredentials.find_many = AsyncMock(
return_value=[encrypted_row, legacy_row, byok_row]
)
prisma.db.litellm_mcpusercredentials.find_many = AsyncMock(return_value=[encrypted_row, legacy_row, byok_row])
results = await list_user_oauth_credentials(prisma, "alice")
@ -326,9 +577,7 @@ async def test_rotate_re_encrypts_byok_with_new_key(monkeypatch):
prisma.db.litellm_mcpusercredentials.update = AsyncMock()
new_master_key = "rotated-salt-key-9999-9999-9999-9999"
await rotate_mcp_user_credentials_master_key(
prisma_client=prisma, new_master_key=new_master_key
)
await rotate_mcp_user_credentials_master_key(prisma_client=prisma, new_master_key=new_master_key)
update_call = prisma.db.litellm_mcpusercredentials.update.call_args
new_stored = update_call.kwargs["data"]["credential_b64"]
@ -356,19 +605,13 @@ async def test_rotate_migrates_legacy_plaintext_rows(monkeypatch):
legacy_row.user_id = "alice"
legacy_row.server_id = "srv-legacy"
legacy_row.credential_b64 = base64.urlsafe_b64encode(b"legacy-plain").decode()
prisma.db.litellm_mcpusercredentials.find_many = AsyncMock(
return_value=[legacy_row]
)
prisma.db.litellm_mcpusercredentials.find_many = AsyncMock(return_value=[legacy_row])
prisma.db.litellm_mcpusercredentials.update = AsyncMock()
new_key = "another-rotation-key-aaaa-bbbb-cccc-dddd"
await rotate_mcp_user_credentials_master_key(
prisma_client=prisma, new_master_key=new_key
)
await rotate_mcp_user_credentials_master_key(prisma_client=prisma, new_master_key=new_key)
new_stored = prisma.db.litellm_mcpusercredentials.update.call_args.kwargs["data"][
"credential_b64"
]
new_stored = prisma.db.litellm_mcpusercredentials.update.call_args.kwargs["data"]["credential_b64"]
monkeypatch.setenv("LITELLM_SALT_KEY", new_key)
assert (
decrypt_value_helper(
@ -396,14 +639,10 @@ async def test_rotate_skips_undecodable_rows():
good_row.server_id = "srv-ok"
good_row.credential_b64 = base64.urlsafe_b64encode(b"good-byok").decode()
prisma.db.litellm_mcpusercredentials.find_many = AsyncMock(
return_value=[bad_row, good_row]
)
prisma.db.litellm_mcpusercredentials.find_many = AsyncMock(return_value=[bad_row, good_row])
prisma.db.litellm_mcpusercredentials.update = AsyncMock()
await rotate_mcp_user_credentials_master_key(
prisma_client=prisma, new_master_key="new-key-xxxx"
)
await rotate_mcp_user_credentials_master_key(prisma_client=prisma, new_master_key="new-key-xxxx")
# Only one update call — the good row.
assert prisma.db.litellm_mcpusercredentials.update.call_count == 1
@ -419,9 +658,7 @@ def _oauth_cred(access_token="at-live", refresh_token=None, expires_in_seconds=N
if refresh_token is not None:
cred["refresh_token"] = refresh_token
if expires_in_seconds is not None:
cred["expires_at"] = (
datetime.now(timezone.utc) + timedelta(seconds=expires_in_seconds)
).isoformat()
cred["expires_at"] = (datetime.now(timezone.utc) + timedelta(seconds=expires_in_seconds)).isoformat()
return cred
@ -438,12 +675,7 @@ def test_expiry_buffer_treats_soon_to_expire_as_expired():
cred = _oauth_cred(expires_in_seconds=30)
assert is_oauth_credential_expired(cred, buffer_seconds=60) is True
# A token comfortably beyond the buffer stays valid.
assert (
is_oauth_credential_expired(
_oauth_cred(expires_in_seconds=600), buffer_seconds=60
)
is False
)
assert is_oauth_credential_expired(_oauth_cred(expires_in_seconds=600), buffer_seconds=60) is False
def test_expiry_past_is_expired_regardless_of_buffer():
@ -465,9 +697,7 @@ async def test_resolve_returns_valid_token_without_refreshing(monkeypatch):
refresh = AsyncMock()
monkeypatch.setattr(db_mod, "refresh_user_oauth_token", refresh)
cred = _oauth_cred(
access_token="at-live", refresh_token="rt-1", expires_in_seconds=600
)
cred = _oauth_cred(access_token="at-live", refresh_token="rt-1", expires_in_seconds=600)
result = await resolve_valid_user_oauth_token(
user_id="alice", server=MagicMock(), cred=cred, prisma_client=MagicMock()
)
@ -483,15 +713,11 @@ async def test_resolve_refreshes_expired_token_with_refresh_token(monkeypatch):
# new token rather than returning None (which left the UI tool list empty).
import litellm.proxy._experimental.mcp_server.db as db_mod
refreshed = _oauth_cred(
access_token="at-fresh", refresh_token="rt-2", expires_in_seconds=3600
)
refreshed = _oauth_cred(access_token="at-fresh", refresh_token="rt-2", expires_in_seconds=3600)
refresh = AsyncMock(return_value=refreshed)
monkeypatch.setattr(db_mod, "refresh_user_oauth_token", refresh)
expired = _oauth_cred(
access_token="at-dead", refresh_token="rt-1", expires_in_seconds=-5
)
expired = _oauth_cred(access_token="at-dead", refresh_token="rt-1", expires_in_seconds=-5)
result = await resolve_valid_user_oauth_token(
user_id="alice", server=MagicMock(), cred=expired, prisma_client=MagicMock()
)
@ -510,9 +736,7 @@ async def test_resolve_refreshes_token_expiring_within_buffer(monkeypatch):
refresh = AsyncMock(return_value=refreshed)
monkeypatch.setattr(db_mod, "refresh_user_oauth_token", refresh)
soon = _oauth_cred(
access_token="at-soon", refresh_token="rt-1", expires_in_seconds=30
)
soon = _oauth_cred(access_token="at-soon", refresh_token="rt-1", expires_in_seconds=30)
result = await resolve_valid_user_oauth_token(
user_id="alice", server=MagicMock(), cred=soon, prisma_client=MagicMock()
)
@ -546,9 +770,7 @@ async def test_resolve_returns_none_when_refresh_fails(monkeypatch):
refresh = AsyncMock(return_value=None)
monkeypatch.setattr(db_mod, "refresh_user_oauth_token", refresh)
expired = _oauth_cred(
access_token="at-dead", refresh_token="rt-1", expires_in_seconds=-5
)
expired = _oauth_cred(access_token="at-dead", refresh_token="rt-1", expires_in_seconds=-5)
result = await resolve_valid_user_oauth_token(
user_id="alice", server=MagicMock(), cred=expired, prisma_client=MagicMock()
)
@ -565,9 +787,7 @@ async def test_resolve_returns_none_for_missing_credential(monkeypatch):
monkeypatch.setattr(db_mod, "refresh_user_oauth_token", refresh)
assert (
await resolve_valid_user_oauth_token(
user_id="alice", server=MagicMock(), cred=None, prisma_client=MagicMock()
)
await resolve_valid_user_oauth_token(user_id="alice", server=MagicMock(), cred=None, prisma_client=MagicMock())
is None
)
assert (
@ -601,19 +821,13 @@ async def test_rotate_user_env_vars_re_encrypts_with_new_key(monkeypatch):
encrypted_old = encrypt_value_helper(json.dumps(values))
prisma = MagicMock()
prisma.db.litellm_mcpuserenvvars.find_many = AsyncMock(
return_value=[_env_var_row(encrypted_old)]
)
prisma.db.litellm_mcpuserenvvars.find_many = AsyncMock(return_value=[_env_var_row(encrypted_old)])
prisma.db.litellm_mcpuserenvvars.update = AsyncMock()
new_master_key = "rotated-env-key-1111-2222-3333-4444"
await rotate_mcp_user_env_vars_master_key(
prisma_client=prisma, new_master_key=new_master_key
)
await rotate_mcp_user_env_vars_master_key(prisma_client=prisma, new_master_key=new_master_key)
new_stored = prisma.db.litellm_mcpuserenvvars.update.call_args.kwargs["data"][
"values_b64"
]
new_stored = prisma.db.litellm_mcpuserenvvars.update.call_args.kwargs["data"]["values_b64"]
assert new_stored != encrypted_old, "rotation must produce different ciphertext"
monkeypatch.setenv("LITELLM_SALT_KEY", new_master_key)
@ -630,18 +844,14 @@ async def test_rotate_user_env_vars_re_encrypts_with_new_key(monkeypatch):
async def test_rotate_user_env_vars_skips_undecryptable_rows():
# A corrupt row must be skipped (not overwritten) so recoverable data is
# preserved and one bad row does not abort the rest of the rotation.
good = _env_var_row(
encrypt_value_helper(json.dumps({"A": "1"})), server_id="srv-ok"
)
good = _env_var_row(encrypt_value_helper(json.dumps({"A": "1"})), server_id="srv-ok")
bad = _env_var_row("!!! not encrypted !!!", server_id="srv-corrupt")
prisma = MagicMock()
prisma.db.litellm_mcpuserenvvars.find_many = AsyncMock(return_value=[bad, good])
prisma.db.litellm_mcpuserenvvars.update = AsyncMock()
await rotate_mcp_user_env_vars_master_key(
prisma_client=prisma, new_master_key="new-key-xxxx"
)
await rotate_mcp_user_env_vars_master_key(prisma_client=prisma, new_master_key="new-key-xxxx")
assert prisma.db.litellm_mcpuserenvvars.update.call_count == 1
where = prisma.db.litellm_mcpuserenvvars.update.call_args.kwargs["where"]
@ -669,9 +879,7 @@ async def test_refresh_user_oauth_token_uses_client_secret_basic(monkeypatch):
monkeypatch.setattr(db_mod, "get_async_httpx_client", lambda **kwargs: mock_client)
monkeypatch.setattr(db_mod, "store_user_oauth_credential", AsyncMock())
monkeypatch.setattr(
db_mod, "get_user_oauth_credential", AsyncMock(return_value={"access_token": "new-at"})
)
monkeypatch.setattr(db_mod, "get_user_oauth_credential", AsyncMock(return_value={"access_token": "new-at"}))
result = await db_mod.refresh_user_oauth_token(
prisma_client=MagicMock(),
@ -710,9 +918,7 @@ async def test_refresh_user_oauth_token_defaults_to_client_secret_post(monkeypat
monkeypatch.setattr(db_mod, "get_async_httpx_client", lambda **kwargs: mock_client)
monkeypatch.setattr(db_mod, "store_user_oauth_credential", AsyncMock())
monkeypatch.setattr(
db_mod, "get_user_oauth_credential", AsyncMock(return_value={"access_token": "new-at"})
)
monkeypatch.setattr(db_mod, "get_user_oauth_credential", AsyncMock(return_value={"access_token": "new-at"}))
await db_mod.refresh_user_oauth_token(
prisma_client=MagicMock(),

View file

@ -6869,6 +6869,7 @@ async def test_call_tool_with_legacy_db_m2m_server_resolves_oauth2_flow():
(None, None),
("", None),
("not a url", None),
("http://[::1", None),
],
)
def test_redact_mcp_resource_url_strips_credentials(url, expected):

View file

@ -3327,9 +3327,35 @@ class TestMCPServerManager:
await manager.invalidate_user_oauth_token_cache("alice", "srv-1")
assert store.invalidations == [("alice", "srv-1")]
@pytest.mark.asyncio
async def test_invalidate_user_oauth_token_cache_drops_legacy_cache_too(self):
"""A per-user token can be served from the legacy per-user token cache as well as the v2
store; the shared invalidation must evict both, or the path not evicted keeps serving a
token minted for a replaced credential row until its TTL."""
class _Store:
async def fetch(self, user_id: str, server_id: str):
return None
async def invalidate(self, user_id: str, server_id: str) -> None:
return None
class _LegacyCache:
def __init__(self) -> None:
self.deletes: list[tuple[str, str]] = []
async def delete(self, user_id: str, server_id: str) -> None:
self.deletes.append((user_id, server_id))
legacy_cache = _LegacyCache()
manager = MCPServerManager(per_user_oauth_token_store=_Store(), per_user_token_cache=legacy_cache)
await manager.invalidate_user_oauth_token_cache("alice", "srv-1")
assert legacy_cache.deletes == [("alice", "srv-1")]
@pytest.mark.asyncio
async def test_invalidate_user_oauth_token_cache_swallows_store_errors(self):
"""A cache-drop failure must not fail the credential write that triggered it."""
"""A cache-drop failure must not fail the credential write that triggered it, and the
legacy cache must still be evicted after the v2 store drop fails."""
class _Store:
async def fetch(self, user_id: str, server_id: str):
@ -3338,7 +3364,35 @@ class TestMCPServerManager:
async def invalidate(self, user_id: str, server_id: str) -> None:
raise RuntimeError("redis down")
manager = MCPServerManager(per_user_oauth_token_store=_Store())
class _LegacyCache:
def __init__(self) -> None:
self.deletes: list[tuple[str, str]] = []
async def delete(self, user_id: str, server_id: str) -> None:
self.deletes.append((user_id, server_id))
legacy_cache = _LegacyCache()
manager = MCPServerManager(per_user_oauth_token_store=_Store(), per_user_token_cache=legacy_cache)
await manager.invalidate_user_oauth_token_cache("alice", "srv-1")
assert legacy_cache.deletes == [("alice", "srv-1")]
@pytest.mark.asyncio
async def test_invalidate_user_oauth_token_cache_swallows_legacy_cache_errors(self):
"""The legacy cache drop is best-effort like the v2 drop: a failure must be logged, never
raised into the credential write that triggered the invalidation."""
class _Store:
async def fetch(self, user_id: str, server_id: str):
return None
async def invalidate(self, user_id: str, server_id: str) -> None:
return None
class _RaisingLegacyCache:
async def delete(self, user_id: str, server_id: str) -> None:
raise RuntimeError("redis down")
manager = MCPServerManager(per_user_oauth_token_store=_Store(), per_user_token_cache=_RaisingLegacyCache())
await manager.invalidate_user_oauth_token_cache("alice", "srv-1")
@pytest.mark.asyncio

View file

@ -2595,6 +2595,135 @@ def test_org_admin_of_multiple_orgs_can_operate_on_both():
assert _user_is_org_admin({"organizations": ["org-A", "org-B"]}, user_obj) is True
# ── LIT-4221: /team/update org-context resolution from team_id ────────────────
from litellm.proxy.auth.auth_checks_organization import (
add_team_org_context_to_request_body,
)
@pytest.mark.asyncio
async def test_add_team_org_context_resolves_org_from_team():
"""For /team/update with only team_id, the target team's org is resolved and
injected so the org-admin route gate can see it. This is what lets an org
admin update a team budget from the Hub UI, which sends team_id, not
organization_id (LIT-4221)."""
async def fetch(team_id: str):
assert team_id == "team-1"
return "org-1"
out = await add_team_org_context_to_request_body(
route="/team/update",
request_body={"team_id": "team-1", "max_budget": 42},
fetch_team_org_id=fetch,
)
assert out == {"team_id": "team-1", "max_budget": 42, "organization_id": "org-1"}
@pytest.mark.asyncio
async def test_add_team_org_context_noop_when_org_id_already_present():
"""If the caller already passed organization_id, no lookup happens and the
body is returned unchanged."""
async def fetch(team_id: str):
raise AssertionError("must not resolve when organization_id is present")
body = {"team_id": "team-1", "organization_id": "org-explicit"}
out = await add_team_org_context_to_request_body(
route="/team/update", request_body=body, fetch_team_org_id=fetch
)
assert out == body
@pytest.mark.asyncio
async def test_add_team_org_context_noop_for_other_routes():
"""Only /team/update opts into org resolution; other routes are untouched."""
async def fetch(team_id: str):
raise AssertionError("must not resolve for a non-opted-in route")
body = {"team_id": "team-1"}
out = await add_team_org_context_to_request_body(
route="/team/delete", request_body=body, fetch_team_org_id=fetch
)
assert out == body
@pytest.mark.asyncio
async def test_add_team_org_context_noop_when_team_has_no_org():
"""A standalone team (no org) resolves to None, so nothing is injected and
the org-admin branch stays unreachable (no blanket access)."""
async def fetch(team_id: str):
return None
body = {"team_id": "team-1"}
out = await add_team_org_context_to_request_body(
route="/team/update", request_body=body, fetch_team_org_id=fetch
)
assert out == body
def test_team_update_gate_allows_org_admin_with_resolved_org():
"""Post-resolution (organization_id present), an org admin of that org clears
the gate for /team/update."""
user_obj = _make_org_admin_user("org-1")
valid_token = UserAPIKeyAuth(user_id="org-admin-user", user_role=LitellmUserRoles.INTERNAL_USER.value)
request = MagicMock(spec=Request)
request.method = "POST"
request.query_params = {}
RouteChecks.non_proxy_admin_allowed_routes_check(
user_obj=user_obj,
_user_role=LitellmUserRoles.INTERNAL_USER.value,
route="/team/update",
request=request,
valid_token=valid_token,
request_data={"team_id": "team-1", "organization_id": "org-1"},
)
def test_team_update_gate_rejects_without_org_context():
"""Without organization_id (i.e. resolution found no org, or a non-org-admin),
the gate still rejects /team/update the fix adds no blanket allow. Guards
against re-widening the route (e.g. dropping it into self_managed_routes)."""
user_obj = _make_org_admin_user("org-1")
valid_token = UserAPIKeyAuth(user_id="org-admin-user", user_role=LitellmUserRoles.INTERNAL_USER.value)
request = MagicMock(spec=Request)
request.method = "POST"
request.query_params = {}
with pytest.raises(Exception):
RouteChecks.non_proxy_admin_allowed_routes_check(
user_obj=user_obj,
_user_role=LitellmUserRoles.INTERNAL_USER.value,
route="/team/update",
request=request,
valid_token=valid_token,
request_data={"team_id": "team-1", "max_budget": 42},
)
def test_team_update_gate_rejects_cross_org_admin_with_resolved_org():
"""Even after the target team's org is resolved, an org admin of a DIFFERENT
org is rejected at the gate (no cross-org escalation)."""
user_obj = _make_org_admin_user("org-1")
valid_token = UserAPIKeyAuth(user_id="org-admin-user", user_role=LitellmUserRoles.INTERNAL_USER.value)
request = MagicMock(spec=Request)
request.method = "POST"
request.query_params = {}
with pytest.raises(Exception):
RouteChecks.non_proxy_admin_allowed_routes_check(
user_obj=user_obj,
_user_role=LitellmUserRoles.INTERNAL_USER.value,
route="/team/update",
request=request,
valid_token=valid_token,
request_data={"team_id": "team-1", "organization_id": "org-2"},
)
@pytest.mark.asyncio
async def test_initialize_pass_through_registers_wildcard_for_auth_subpath():
"""

View file

@ -5134,3 +5134,93 @@ def test_stamp_oauth2_flow_ignores_non_oauth2():
payload = _oauth2_create_payload(auth_type="none")
mgmt_endpoints.stamp_omitted_oauth2_flow(payload)
assert payload.oauth2_flow is None
async def _run_edit(old_record, updated_record, purge_mock=None):
from litellm.proxy.management_endpoints.mcp_management_endpoints import edit_mcp_server
server_id = updated_record.server_id
with (
patch("litellm.proxy.management_endpoints.mcp_management_endpoints.MCP_AVAILABLE", True),
patch(
"litellm.proxy.management_endpoints.mcp_management_endpoints.get_prisma_client_or_throw",
return_value=MagicMock(),
),
patch(
"litellm.proxy.management_endpoints.mcp_management_endpoints.get_mcp_server",
AsyncMock(side_effect=old_record)
if isinstance(old_record, Exception)
else AsyncMock(return_value=old_record),
),
patch(
"litellm.proxy.management_endpoints.mcp_management_endpoints.update_mcp_server",
AsyncMock(return_value=updated_record),
),
patch(
"litellm.proxy.management_endpoints.mcp_management_endpoints.validate_and_normalize_mcp_server_payload",
autospec=True,
),
patch("litellm.proxy.management_endpoints.mcp_management_endpoints.global_mcp_server_manager") as mock_manager,
patch(
"litellm.proxy.management_endpoints.mcp_management_endpoints.purge_user_oauth_credentials_for_server",
purge_mock if purge_mock is not None else AsyncMock(return_value=1),
) as mock_purge,
):
mock_manager.update_server = AsyncMock()
mock_manager.reload_servers_from_database = AsyncMock()
payload = UpdateMCPServerRequest(server_id=server_id, alias=updated_record.alias, url=updated_record.url)
user_auth = UserAPIKeyAuth(user_id="admin", user_role=LitellmUserRoles.PROXY_ADMIN)
result = await edit_mcp_server(payload=payload, user_api_key_dict=user_auth)
return result, mock_purge
@pytest.mark.asyncio
async def test_edit_mcp_server_purges_user_tokens_on_mint_relevant_change():
server_id = str(uuid.uuid4())
old = generate_mock_mcp_server_db_record(server_id=server_id, url="https://old.example.com/mcp")
updated = generate_mock_mcp_server_db_record(server_id=server_id, url="https://new.example.com/mcp")
result, mock_purge = await _run_edit(old, updated)
assert result.server_id == server_id
mock_purge.assert_awaited_once()
assert mock_purge.await_args.args[1] == server_id
@pytest.mark.asyncio
async def test_edit_mcp_server_skips_purge_when_identity_unchanged():
server_id = str(uuid.uuid4())
old = generate_mock_mcp_server_db_record(server_id=server_id, alias="Before")
updated = generate_mock_mcp_server_db_record(server_id=server_id, alias="After")
result, mock_purge = await _run_edit(old, updated)
assert result.server_id == server_id
mock_purge.assert_not_awaited()
@pytest.mark.asyncio
async def test_edit_mcp_server_purge_failure_does_not_fail_the_edit():
"""The purge is best-effort: a purge exception after a successful update must be swallowed and
logged, never turned into an error response for an edit whose primary job already succeeded."""
server_id = str(uuid.uuid4())
old = generate_mock_mcp_server_db_record(server_id=server_id, url="https://old.example.com/mcp")
updated = generate_mock_mcp_server_db_record(server_id=server_id, url="https://new.example.com/mcp")
result, mock_purge = await _run_edit(old, updated, purge_mock=AsyncMock(side_effect=RuntimeError("db down")))
assert result.server_id == server_id
mock_purge.assert_awaited_once()
@pytest.mark.asyncio
async def test_edit_mcp_server_snapshot_failure_skips_purge_but_edit_succeeds():
"""The pre-update snapshot read is advisory (it only feeds the purge decision); a read failure
must skip the stale-token check with a warning, never fail the edit itself."""
server_id = str(uuid.uuid4())
updated = generate_mock_mcp_server_db_record(server_id=server_id, url="https://new.example.com/mcp")
result, mock_purge = await _run_edit(RuntimeError("db read failed"), updated)
assert result.server_id == server_id
mock_purge.assert_not_awaited()

View file

@ -0,0 +1,63 @@
import json
from pathlib import Path
from litellm.litellm_core_utils.get_llm_provider_logic import get_llm_provider
MUSE_SPARK_MODEL = "meta/muse-spark-1.1"
def test_muse_spark_1_1_model_info():
json_path = Path(__file__).parents[2] / "model_prices_and_context_window.json"
with open(json_path) as f:
model_cost = json.load(f)
info = model_cost.get(MUSE_SPARK_MODEL)
assert info is not None, f"{MUSE_SPARK_MODEL} not found in model_prices_and_context_window.json"
assert info["litellm_provider"] == "meta"
assert info["mode"] == "chat"
assert info["input_cost_per_token"] == 1.25e-06
assert info["output_cost_per_token"] == 4.25e-06
assert info["cache_read_input_token_cost"] == 1.5e-07
assert info["max_input_tokens"] == 1048576
assert info["max_output_tokens"] == 131072
assert info["max_tokens"] == 131072
assert info["supports_function_calling"] is True
assert info["supports_parallel_function_calling"] is True
assert info["supports_prompt_caching"] is True
assert info["supports_reasoning"] is True
assert info["supports_response_schema"] is True
assert info["supports_tool_choice"] is True
assert info["supports_vision"] is True
assert info["supports_pdf_input"] is True
assert info["supports_web_search"] is True
assert info["supports_minimal_reasoning_effort"] is True
assert info["supports_xhigh_reasoning_effort"] is True
assert info["supported_endpoints"] == ["/v1/chat/completions", "/v1/responses", "/v1/messages"]
assert info["supported_modalities"] == ["text", "image", "video"]
assert info["supported_output_modalities"] == ["text"]
routed_model, provider, _, api_base = get_llm_provider(model=MUSE_SPARK_MODEL, api_key="sk-test")
assert routed_model == "muse-spark-1.1"
assert provider == "meta"
assert api_base == "https://api.meta.ai/v1"
def test_muse_spark_1_1_backup_matches_main():
"""Ensure the bundled model cost map stays in sync with the canonical file."""
repo_root = Path(__file__).parents[2]
main_path = repo_root / "model_prices_and_context_window.json"
backup_path = repo_root / "litellm" / "model_prices_and_context_window_backup.json"
with open(main_path) as f:
main_cost = json.load(f)
with open(backup_path) as f:
backup_cost = json.load(f)
assert backup_cost.get(MUSE_SPARK_MODEL) == main_cost.get(MUSE_SPARK_MODEL), (
f"{MUSE_SPARK_MODEL} differs between main and backup model cost maps"
)

View file

@ -1,6 +1,6 @@
{
"@typescript-eslint/no-explicit-any": 1980,
"complexity": 128,
"@typescript-eslint/no-explicit-any": 1978,
"complexity": 129,
"local/no-large-inline-object-arg": 513,
"local/no-long-condition-chain": 233,
"max-depth": 59,

View file

@ -4,32 +4,40 @@
"count": 1
}
},
"src/app/(dashboard)/api-reference/APIReferenceView.tsx": {
"src/app/(dashboard)/admin-panel/_components/AdminPanel.tsx": {
"no-restricted-imports": {
"count": 1
},
"react-hooks/set-state-in-effect": {
"count": 1
}
},
"src/app/(dashboard)/api-reference/_components/APIReferenceView.tsx": {
"no-restricted-imports": {
"count": 1
}
},
"src/app/(dashboard)/budgets/components/budget_modal.tsx": {
"src/app/(dashboard)/budgets/_components/budget_modal.tsx": {
"no-restricted-imports": {
"count": 1
}
},
"src/app/(dashboard)/budgets/components/budget_panel.test.tsx": {
"src/app/(dashboard)/budgets/_components/budget_panel.test.tsx": {
"unused-imports/no-unused-imports": {
"count": 2
}
},
"src/app/(dashboard)/budgets/components/budget_panel.tsx": {
"src/app/(dashboard)/budgets/_components/budget_panel.tsx": {
"no-restricted-imports": {
"count": 1
}
},
"src/app/(dashboard)/budgets/components/edit_budget_modal.tsx": {
"src/app/(dashboard)/budgets/_components/edit_budget_modal.tsx": {
"no-restricted-imports": {
"count": 1
}
},
"src/app/(dashboard)/caching/components/cache_dashboard.tsx": {
"src/app/(dashboard)/caching/_components/cache_dashboard.tsx": {
"no-restricted-imports": {
"count": 1
},
@ -40,17 +48,17 @@
"count": 2
}
},
"src/app/(dashboard)/caching/components/cache_health.tsx": {
"src/app/(dashboard)/caching/_components/cache_health.tsx": {
"no-restricted-imports": {
"count": 1
}
},
"src/app/(dashboard)/caching/components/cache_settings/RedisTypeSelector.tsx": {
"src/app/(dashboard)/caching/_components/cache_settings/RedisTypeSelector.tsx": {
"no-restricted-imports": {
"count": 1
}
},
"src/app/(dashboard)/caching/components/cache_settings/index.tsx": {
"src/app/(dashboard)/caching/_components/cache_settings/index.tsx": {
"no-restricted-imports": {
"count": 1
},
@ -136,36 +144,26 @@
"count": 2
}
},
"src/app/(dashboard)/guardrails-monitor/components/EvaluationSettingsModal.tsx": {
"src/app/(dashboard)/guardrails-monitor/_components/EvaluationSettingsModal.tsx": {
"react-hooks/set-state-in-effect": {
"count": 1
}
},
"src/app/(dashboard)/guardrails-monitor/components/GuardrailDetail.tsx": {
"src/app/(dashboard)/guardrails-monitor/_components/GuardrailDetail.tsx": {
"no-nested-ternary": {
"count": 3
}
},
"src/app/(dashboard)/guardrails-monitor/components/GuardrailsMonitorView.tsx": {
"src/app/(dashboard)/guardrails-monitor/_components/GuardrailsMonitorView.tsx": {
"no-restricted-imports": {
"count": 1
}
},
"src/app/(dashboard)/guardrails-monitor/components/GuardrailsOverview.tsx": {
"src/app/(dashboard)/guardrails-monitor/_components/GuardrailsOverview.tsx": {
"no-nested-ternary": {
"count": 8
}
},
"src/app/(dashboard)/guardrails-monitor/components/ScoreChart.test.tsx": {
"react/display-name": {
"count": 1
}
},
"src/app/(dashboard)/guardrails-monitor/components/ScoreChart.tsx": {
"no-restricted-imports": {
"count": 1
}
},
"src/app/(dashboard)/hooks/accessGroups/useAccessGroupDetails.ts": {
"no-restricted-syntax": {
"count": 1
@ -326,7 +324,7 @@
"count": 2
}
},
"src/app/(dashboard)/memory/components/MemoryView.tsx": {
"src/app/(dashboard)/memory/_components/MemoryView.tsx": {
"react-hooks/set-state-in-effect": {
"count": 1
}
@ -373,6 +371,22 @@
"count": 1
}
},
"src/app/(dashboard)/old-usage/_components/usage.tsx": {
"no-restricted-imports": {
"count": 2
},
"react-hooks/immutability": {
"count": 1
},
"react-hooks/purity": {
"count": 1
}
},
"src/app/(dashboard)/organizations/_components/organizations.tsx": {
"no-restricted-imports": {
"count": 1
}
},
"src/app/(dashboard)/playground/components/chat_ui/AdditionalModelSettings.tsx": {
"no-restricted-imports": {
"count": 1
@ -522,7 +536,7 @@
"count": 1
}
},
"src/app/(dashboard)/projects/components/ProjectDetailsPage.tsx": {
"src/app/(dashboard)/projects/_components/ProjectDetailsPage.tsx": {
"no-nested-ternary": {
"count": 3
},
@ -530,17 +544,17 @@
"count": 1
}
},
"src/app/(dashboard)/projects/components/ProjectKeysSection.tsx": {
"src/app/(dashboard)/projects/_components/ProjectKeysSection.tsx": {
"react-hooks/set-state-in-effect": {
"count": 1
}
},
"src/app/(dashboard)/projects/components/ProjectModals/ProjectBaseForm.tsx": {
"src/app/(dashboard)/projects/_components/ProjectModals/ProjectBaseForm.tsx": {
"react-hooks/set-state-in-effect": {
"count": 2
}
},
"src/app/(dashboard)/projects/components/ProjectsPage.tsx": {
"src/app/(dashboard)/projects/_components/ProjectsPage.tsx": {
"react-hooks/set-state-in-effect": {
"count": 1
}
@ -649,6 +663,14 @@
"count": 1
}
},
"src/app/(dashboard)/router-settings/_components/general_settings.tsx": {
"no-nested-ternary": {
"count": 3
},
"no-restricted-imports": {
"count": 2
}
},
"src/app/(dashboard)/search-tools/_components/CreateSearchTools.tsx": {
"no-restricted-imports": {
"count": 1
@ -851,14 +873,6 @@
"count": 1
}
},
"src/components/AdminPanel.tsx": {
"no-restricted-imports": {
"count": 1
},
"react-hooks/set-state-in-effect": {
"count": 1
}
},
"src/components/CreateUserButton.tsx": {
"no-restricted-imports": {
"count": 1
@ -1520,14 +1534,6 @@
"count": 1
}
},
"src/components/general_settings.tsx": {
"no-nested-ternary": {
"count": 3
},
"no-restricted-imports": {
"count": 2
}
},
"src/components/guardrails.tsx": {
"react-hooks/set-state-in-effect": {
"count": 1
@ -2028,11 +2034,6 @@
"count": 1
}
},
"src/components/organizations.tsx": {
"no-restricted-imports": {
"count": 1
}
},
"src/components/page_utils.test.ts": {
"max-nested-callbacks": {
"count": 3
@ -2371,17 +2372,6 @@
"count": 1
}
},
"src/components/usage.tsx": {
"no-restricted-imports": {
"count": 2
},
"react-hooks/immutability": {
"count": 1
},
"react-hooks/purity": {
"count": 1
}
},
"src/components/user_agent_activity.tsx": {
"no-restricted-imports": {
"count": 2

View file

@ -34,6 +34,7 @@
"react-json-view-lite": "2.5.0",
"react-markdown": "9.1.0",
"react-syntax-highlighter": "15.6.6",
"recharts": "3.9.2",
"remark-gfm": "4.0.1",
"tailwind-merge": "3.4.0",
"uuid": "14.0.0"
@ -2927,6 +2928,32 @@
"npm": ">=9.5.0"
}
},
"node_modules/@reduxjs/toolkit": {
"version": "2.12.0",
"resolved": "https://registry.npmjs.org/@reduxjs/toolkit/-/toolkit-2.12.0.tgz",
"integrity": "sha512-KiT+RzZbp6mQET+Mg+h2c97+9j1sNflUxQkIHI7Yuzf6Peu+OYpmkn6nbHWmLLWj+1ZODUJFwGZ7gx3L9R9EOw==",
"license": "MIT",
"dependencies": {
"@standard-schema/spec": "^1.0.0",
"@standard-schema/utils": "^0.3.0",
"immer": "^11.0.0",
"redux": "^5.0.1",
"redux-thunk": "^3.1.0",
"reselect": "^5.1.0"
},
"peerDependencies": {
"react": "^16.9.0 || ^17.0.0 || ^18 || ^19",
"react-redux": "^7.2.1 || ^8.1.3 || ^9.0.0"
},
"peerDependenciesMeta": {
"react": {
"optional": true
},
"react-redux": {
"optional": true
}
}
},
"node_modules/@rollup/rollup-android-arm-eabi": {
"version": "4.61.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.61.1.tgz",
@ -3284,6 +3311,18 @@
"dev": true,
"license": "MIT"
},
"node_modules/@standard-schema/spec": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz",
"integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==",
"license": "MIT"
},
"node_modules/@standard-schema/utils": {
"version": "0.3.0",
"resolved": "https://registry.npmjs.org/@standard-schema/utils/-/utils-0.3.0.tgz",
"integrity": "sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g==",
"license": "MIT"
},
"node_modules/@swc/helpers": {
"version": "0.5.15",
"resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.15.tgz",
@ -3804,6 +3843,42 @@
"react-dom": ">=16.6.0"
}
},
"node_modules/@tremor/react/node_modules/eventemitter3": {
"version": "4.0.7",
"resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-4.0.7.tgz",
"integrity": "sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw==",
"license": "MIT"
},
"node_modules/@tremor/react/node_modules/react-is": {
"version": "18.3.1",
"resolved": "https://registry.npmjs.org/react-is/-/react-is-18.3.1.tgz",
"integrity": "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==",
"license": "MIT"
},
"node_modules/@tremor/react/node_modules/recharts": {
"version": "2.15.4",
"resolved": "https://registry.npmjs.org/recharts/-/recharts-2.15.4.tgz",
"integrity": "sha512-UT/q6fwS3c1dHbXv2uFgYJ9BMFHu3fwnd7AYZaEQhXuYQ4hgsxLvsUXzGdKeZrW5xopzDCvuA2N41WJ88I7zIw==",
"deprecated": "1.x and 2.x branches are no longer active. Bump to Recharts v3 to receive latest features and bugfixes. See https://github.com/recharts/recharts/wiki/3.0-migration-guide",
"license": "MIT",
"dependencies": {
"clsx": "^2.0.0",
"eventemitter3": "^4.0.1",
"lodash": "^4.17.21",
"react-is": "^18.3.1",
"react-smooth": "^4.0.4",
"recharts-scale": "^0.4.4",
"tiny-invariant": "^1.3.1",
"victory-vendor": "^36.6.8"
},
"engines": {
"node": ">=14"
},
"peerDependencies": {
"react": "^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
"react-dom": "^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
}
},
"node_modules/@tremor/react/node_modules/tailwind-merge": {
"version": "2.6.1",
"resolved": "https://registry.npmjs.org/tailwind-merge/-/tailwind-merge-2.6.1.tgz",
@ -3814,6 +3889,28 @@
"url": "https://github.com/sponsors/dcastil"
}
},
"node_modules/@tremor/react/node_modules/victory-vendor": {
"version": "36.9.2",
"resolved": "https://registry.npmjs.org/victory-vendor/-/victory-vendor-36.9.2.tgz",
"integrity": "sha512-PnpQQMuxlwYdocC8fIJqVXvkeViHYzotI+NJrCuav0ZYFoq912ZHBk3mCeuj+5/VpodOjPe1z0Fk2ihgzlXqjQ==",
"license": "MIT AND ISC",
"dependencies": {
"@types/d3-array": "^3.0.3",
"@types/d3-ease": "^3.0.0",
"@types/d3-interpolate": "^3.0.1",
"@types/d3-scale": "^4.0.2",
"@types/d3-shape": "^3.1.0",
"@types/d3-time": "^3.0.0",
"@types/d3-timer": "^3.0.0",
"d3-array": "^3.1.6",
"d3-ease": "^3.0.1",
"d3-interpolate": "^3.0.1",
"d3-scale": "^4.0.2",
"d3-shape": "^3.1.0",
"d3-time": "^3.0.0",
"d3-timer": "^3.0.1"
}
},
"node_modules/@tybys/wasm-util": {
"version": "0.10.3",
"resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.3.tgz",
@ -4069,6 +4166,12 @@
"integrity": "sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==",
"license": "MIT"
},
"node_modules/@types/use-sync-external-store": {
"version": "0.0.6",
"resolved": "https://registry.npmjs.org/@types/use-sync-external-store/-/use-sync-external-store-0.0.6.tgz",
"integrity": "sha512-zFDAD+tlpf2r4asuHEj0XH6pY6i0g5NeAHPn+15wk3BV6JA69eERFXC1gyGThDkVa1zCyKr5jox1+2LbV/AMLg==",
"license": "MIT"
},
"node_modules/@typescript-eslint/eslint-plugin": {
"version": "8.60.1",
"resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.60.1.tgz",
@ -6309,6 +6412,16 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/es-toolkit": {
"version": "1.49.0",
"resolved": "https://registry.npmjs.org/es-toolkit/-/es-toolkit-1.49.0.tgz",
"integrity": "sha512-G5iZ6Pc/FNRY/soKZHC+TxGDD83rHUDXxzaWhGCX44vAv/tMs56WMusnm/KMNK+luUPsgA9U28cGr4RDlSzL2g==",
"license": "MIT",
"workspaces": [
"docs",
"benchmarks"
]
},
"node_modules/esbuild": {
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.1.tgz",
@ -6872,9 +6985,9 @@
}
},
"node_modules/eventemitter3": {
"version": "4.0.7",
"resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-4.0.7.tgz",
"integrity": "sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw==",
"version": "5.0.4",
"resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-5.0.4.tgz",
"integrity": "sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==",
"license": "MIT"
},
"node_modules/expect-type": {
@ -6901,9 +7014,9 @@
"license": "MIT"
},
"node_modules/fast-equals": {
"version": "5.4.0",
"resolved": "https://registry.npmjs.org/fast-equals/-/fast-equals-5.4.0.tgz",
"integrity": "sha512-jt2DW/aNFNwke7AUd+Z+e6pz39KO5rzdbbFCg2sGafS4mk13MI7Z8O5z9cADNn5lhGODIgLwug6TZO2ctf7kcw==",
"version": "5.4.1",
"resolved": "https://registry.npmjs.org/fast-equals/-/fast-equals-5.4.1.tgz",
"integrity": "sha512-DjlFSM5Pk9cGcL0q5QXl66eGzx0N6szNgaswwc5ZphlBohjTVJSnGgI+rJVOgOi65qUoQnDZN4nDqi33udtydQ==",
"license": "MIT",
"engines": {
"node": ">=6.0.0"
@ -7688,6 +7801,16 @@
"node": ">= 4"
}
},
"node_modules/immer": {
"version": "11.1.11",
"resolved": "https://registry.npmjs.org/immer/-/immer-11.1.11.tgz",
"integrity": "sha512-qzXuyXAkPySAGYkfsAwodDPWT8Zm7/Uo5BNt4BjhMhG5WlWyZZ4wQqnWwdS8kjlQ1Cwu6gjw3A6+0gTQwlyYtw==",
"license": "MIT",
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/immer"
}
},
"node_modules/import-fresh": {
"version": "3.3.1",
"resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz",
@ -11589,7 +11712,6 @@
"version": "17.0.2",
"resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz",
"integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==",
"dev": true,
"license": "MIT"
},
"node_modules/react-json-view-lite": {
@ -11631,6 +11753,29 @@
"react": ">=18"
}
},
"node_modules/react-redux": {
"version": "9.3.0",
"resolved": "https://registry.npmjs.org/react-redux/-/react-redux-9.3.0.tgz",
"integrity": "sha512-KQopgqFo/p/fgmAs5qz6p5RWaNAzq40WAu7fJIXnQpYxFPbJYtsJPWvGeF2rOBaY/kEuV77AVsX8TsQzKm+A/g==",
"license": "MIT",
"dependencies": {
"@types/use-sync-external-store": "^0.0.6",
"use-sync-external-store": "^1.4.0"
},
"peerDependencies": {
"@types/react": "^18.2.25 || ^19",
"react": "^18.0 || ^19",
"redux": "^5.0.0"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"redux": {
"optional": true
}
}
},
"node_modules/react-smooth": {
"version": "4.0.4",
"resolved": "https://registry.npmjs.org/react-smooth/-/react-smooth-4.0.4.tgz",
@ -11707,26 +11852,33 @@
}
},
"node_modules/recharts": {
"version": "2.15.4",
"resolved": "https://registry.npmjs.org/recharts/-/recharts-2.15.4.tgz",
"integrity": "sha512-UT/q6fwS3c1dHbXv2uFgYJ9BMFHu3fwnd7AYZaEQhXuYQ4hgsxLvsUXzGdKeZrW5xopzDCvuA2N41WJ88I7zIw==",
"version": "3.9.2",
"resolved": "https://registry.npmjs.org/recharts/-/recharts-3.9.2.tgz",
"integrity": "sha512-G4fy+Pk46RaXgwWMh+Nzhyo/lbFAVqXo9gtetlyehe6Ehge9CsgDuOTwQDD+i1+llaLktNBiNq4bhnGlDRXFtw==",
"license": "MIT",
"workspaces": [
"www"
],
"dependencies": {
"clsx": "^2.0.0",
"eventemitter3": "^4.0.1",
"lodash": "^4.17.21",
"react-is": "^18.3.1",
"react-smooth": "^4.0.4",
"recharts-scale": "^0.4.4",
"tiny-invariant": "^1.3.1",
"victory-vendor": "^36.6.8"
"@reduxjs/toolkit": "^1.9.0 || 2.x.x",
"clsx": "^2.1.1",
"decimal.js-light": "^2.5.1",
"es-toolkit": "^1.39.3",
"eventemitter3": "^5.0.1",
"immer": "^11.1.8",
"react-redux": "8.x.x || 9.x.x",
"reselect": "5.2.0",
"tiny-invariant": "^1.3.3",
"use-sync-external-store": "^1.2.2",
"victory-vendor": "^37.0.2"
},
"engines": {
"node": ">=14"
"node": ">=18"
},
"peerDependencies": {
"react": "^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
"react-dom": "^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
"react-dom": "^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
"react-is": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
}
},
"node_modules/recharts-scale": {
@ -11738,12 +11890,6 @@
"decimal.js-light": "^2.4.1"
}
},
"node_modules/recharts/node_modules/react-is": {
"version": "18.3.1",
"resolved": "https://registry.npmjs.org/react-is/-/react-is-18.3.1.tgz",
"integrity": "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==",
"license": "MIT"
},
"node_modules/redent": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/redent/-/redent-3.0.0.tgz",
@ -11758,6 +11904,21 @@
"node": ">=8"
}
},
"node_modules/redux": {
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/redux/-/redux-5.0.1.tgz",
"integrity": "sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w==",
"license": "MIT"
},
"node_modules/redux-thunk": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/redux-thunk/-/redux-thunk-3.1.0.tgz",
"integrity": "sha512-NW2r5T6ksUKXCabzhL9z+h206HQw/NJkcLm1GPImRQ8IzfXwRGqjVhKJGauHirT0DAuyy6hjdnMZaRoAcy0Klw==",
"license": "MIT",
"peerDependencies": {
"redux": "^5.0.0"
}
},
"node_modules/reflect.getprototypeof": {
"version": "1.0.10",
"resolved": "https://registry.npmjs.org/reflect.getprototypeof/-/reflect.getprototypeof-1.0.10.tgz",
@ -13429,9 +13590,9 @@
}
},
"node_modules/victory-vendor": {
"version": "36.9.2",
"resolved": "https://registry.npmjs.org/victory-vendor/-/victory-vendor-36.9.2.tgz",
"integrity": "sha512-PnpQQMuxlwYdocC8fIJqVXvkeViHYzotI+NJrCuav0ZYFoq912ZHBk3mCeuj+5/VpodOjPe1z0Fk2ihgzlXqjQ==",
"version": "37.3.6",
"resolved": "https://registry.npmjs.org/victory-vendor/-/victory-vendor-37.3.6.tgz",
"integrity": "sha512-SbPDPdDBYp+5MJHhBCAyI7wKM3d5ivekigc2Dk2s7pgbZ9wIgIBYGVw4zGHBml/qTFbexrofXW6Gu4noGxrOwQ==",
"license": "MIT AND ISC",
"dependencies": {
"@types/d3-array": "^3.0.3",

View file

@ -50,6 +50,7 @@
"react-json-view-lite": "2.5.0",
"react-markdown": "9.1.0",
"react-syntax-highlighter": "15.6.6",
"recharts": "3.9.2",
"remark-gfm": "4.0.1",
"tailwind-merge": "3.4.0",
"uuid": "14.0.0"

View file

@ -1,6 +1,6 @@
"use client";
import { AccessGroupsPage } from "./components/AccessGroupsPage";
import { AccessGroupsPage } from "./_components/AccessGroupsPage";
import useAuthorized from "@/app/(dashboard)/hooks/useAuthorized";
export default function AccessGroups() {

View file

@ -8,34 +8,34 @@ const mockGetAllowedIPs = vi.fn();
const mockAddAllowedIP = vi.fn();
const mockDeleteAllowedIP = vi.fn();
vi.mock("./networking", () => ({
vi.mock("@/components/networking", () => ({
getSSOSettings: (...args: unknown[]) => mockGetSSOSettings(...args),
getAllowedIPs: (...args: unknown[]) => mockGetAllowedIPs(...args),
addAllowedIP: (...args: unknown[]) => mockAddAllowedIP(...args),
deleteAllowedIP: (...args: unknown[]) => mockDeleteAllowedIP(...args),
}));
vi.mock("./constants", () => ({
vi.mock("@/components/constants", () => ({
useBaseUrl: () => "http://localhost:4000",
}));
vi.mock("./Settings/AdminSettings/SSOSettings/SSOSettings", () => ({
vi.mock("@/components/Settings/AdminSettings/SSOSettings/SSOSettings", () => ({
default: () => <div>SSO Settings</div>,
}));
vi.mock("./Settings/AdminSettings/UISettings/UISettings", () => ({
vi.mock("@/components/Settings/AdminSettings/UISettings/UISettings", () => ({
default: () => <div>UI Settings</div>,
}));
vi.mock("./SCIM", () => ({
vi.mock("@/components/SCIM", () => ({
default: () => <div>SCIM Config</div>,
}));
vi.mock("./SSOModals", () => ({
vi.mock("@/components/SSOModals", () => ({
default: () => <div>SSO Modals</div>,
}));
vi.mock("./UIAccessControlForm", () => ({
vi.mock("@/components/UIAccessControlForm", () => ({
default: () => <div>UI Access Control Form</div>,
}));

View file

@ -16,18 +16,18 @@ import {
} from "@tremor/react";
import { Alert, Button as Button2, Form, Input, Modal, Space, Tabs, Typography } from "antd";
import React, { useEffect, useState } from "react";
import NewBadge from "./common_components/NewBadge";
import { useBaseUrl } from "./constants";
import NotificationsManager from "./molecules/notifications_manager";
import { addAllowedIP, deleteAllowedIP, getAllowedIPs, getSSOSettings } from "./networking";
import SCIMConfig from "./SCIM";
import LoggingSettings from "./Settings/AdminSettings/LoggingSettings/LoggingSettings";
import SSOSettings from "./Settings/AdminSettings/SSOSettings/SSOSettings";
import UISettings from "./Settings/AdminSettings/UISettings/UISettings";
import HashicorpVault from "./Settings/AdminSettings/HashicorpVault/HashicorpVault";
import PluginSettings from "./Settings/AdminSettings/PluginSettings/PluginSettings";
import SSOModals from "./SSOModals";
import UIAccessControlForm from "./UIAccessControlForm";
import NewBadge from "@/components/common_components/NewBadge";
import { useBaseUrl } from "@/components/constants";
import NotificationsManager from "@/components/molecules/notifications_manager";
import { addAllowedIP, deleteAllowedIP, getAllowedIPs, getSSOSettings } from "@/components/networking";
import SCIMConfig from "@/components/SCIM";
import LoggingSettings from "@/components/Settings/AdminSettings/LoggingSettings/LoggingSettings";
import SSOSettings from "@/components/Settings/AdminSettings/SSOSettings/SSOSettings";
import UISettings from "@/components/Settings/AdminSettings/UISettings/UISettings";
import HashicorpVault from "@/components/Settings/AdminSettings/HashicorpVault/HashicorpVault";
import PluginSettings from "@/components/Settings/AdminSettings/PluginSettings/PluginSettings";
import SSOModals from "@/components/SSOModals";
import UIAccessControlForm from "@/components/UIAccessControlForm";
const { Title, Paragraph, Text } = Typography;

View file

@ -1,6 +1,6 @@
"use client";
import AdminPanel from "@/components/AdminPanel";
import AdminPanel from "./_components/AdminPanel";
import useAuthorized from "@/app/(dashboard)/hooks/useAuthorized";
import useProxySettings from "@/app/(dashboard)/hooks/proxySettings/useProxySettings";

View file

@ -2,7 +2,7 @@ import { render } from "@testing-library/react";
import { describe, expect, it, vi } from "vitest";
import APIReferenceView from "./APIReferenceView";
vi.mock("./components/CodeBlock", () => ({
vi.mock("@/components/CodeBlock", () => ({
__esModule: true,
default: ({ code }: { code: string }) => <pre data-testid="api-reference-code-block">{code}</pre>,
}));

View file

@ -1,8 +1,8 @@
"use client";
import React from "react";
import { Text, Tab, TabGroup, TabList, TabPanel, TabPanels, Grid } from "@tremor/react";
import CodeBlock from "./components/CodeBlock";
import DocLink from "@/app/(dashboard)/api-reference/components/DocLink";
import CodeBlock from "@/components/CodeBlock";
import DocLink from "./DocLink";
interface ApiRefProps {
proxySettings: {

View file

@ -1,6 +1,6 @@
"use client";
import APIReferenceView from "@/app/(dashboard)/api-reference/APIReferenceView";
import APIReferenceView from "./_components/APIReferenceView";
import { DeprecationBanner } from "@/components/DeprecationBanner";
import useAuthorized from "@/app/(dashboard)/hooks/useAuthorized";
import useProxySettings from "@/app/(dashboard)/hooks/proxySettings/useProxySettings";

View file

@ -1,6 +1,6 @@
"use client";
import BudgetPanel from "./components/budget_panel";
import BudgetPanel from "./_components/budget_panel";
import useAuthorized from "@/app/(dashboard)/hooks/useAuthorized";
export default function Budgets() {

View file

@ -1,6 +1,6 @@
"use client";
import CacheDashboard from "./components/cache_dashboard";
import CacheDashboard from "./_components/cache_dashboard";
import useAuthorized from "@/app/(dashboard)/hooks/useAuthorized";
export default function Caching() {

View file

@ -5,7 +5,7 @@ import userEvent from "@testing-library/user-event";
import { renderWithProviders } from "../../../../../tests/test-utils";
import HowItWorks from "./how_it_works";
vi.mock("@/app/(dashboard)/api-reference/components/CodeBlock", () => ({
vi.mock("@/components/CodeBlock", () => ({
default: ({ code }: { code: string }) => <pre data-testid="code-block">{code}</pre>,
}));

View file

@ -1,6 +1,6 @@
import React, { useState, useMemo } from "react";
import { Text, TextInput } from "@tremor/react";
import CodeBlock from "@/app/(dashboard)/api-reference/components/CodeBlock";
import CodeBlock from "@/components/CodeBlock";
const HowItWorks: React.FC = () => {
const [responseCost, setResponseCost] = useState("");

View file

@ -0,0 +1,42 @@
import React from "react";
import { describe, it, expect } from "vitest";
import { screen } from "@testing-library/react";
import { renderWithProviders } from "../../../../../tests/test-utils";
import { ScoreChart } from "./ScoreChart";
describe("ScoreChart", () => {
it("should render the title", () => {
renderWithProviders(<ScoreChart />);
expect(screen.getByText("Request Outcomes Over Time")).toBeInTheDocument();
});
it("should show empty state when no data is provided", () => {
renderWithProviders(<ScoreChart />);
expect(screen.getByText("No chart data for this period")).toBeInTheDocument();
});
it("should show empty state when data is an empty array", () => {
renderWithProviders(<ScoreChart data={[]} />);
expect(screen.getByText("No chart data for this period")).toBeInTheDocument();
});
it("should render the chart when data is provided", () => {
const data = [
{ date: "2026-03-01", passed: 10, blocked: 2 },
{ date: "2026-03-02", passed: 15, blocked: 1 },
];
const { container } = renderWithProviders(<ScoreChart data={data} />);
expect(screen.queryByText("No chart data for this period")).not.toBeInTheDocument();
expect(screen.getByText("passed")).toBeInTheDocument();
expect(screen.getByText("blocked")).toBeInTheDocument();
expect(screen.getAllByText(/2026-03-01/).length).toBeGreaterThan(0);
expect(screen.getAllByText(/2026-03-02/).length).toBeGreaterThan(0);
const bars = container.querySelectorAll(".recharts-bar");
expect(bars).toHaveLength(2);
});
});

View file

@ -0,0 +1,44 @@
import React from "react";
import { BarChart } from "@/components/shared/charts";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
/**
* Overview chart: Request Outcomes Over Time (passed vs blocked).
* Stacked bar chart. Data from usage/overview API (chart array).
*/
interface ScoreChartProps {
data?: Array<{ date: string; passed: number; blocked: number }>;
}
export function ScoreChart({ data }: ScoreChartProps) {
const chartData = data && data.length > 0 ? data : [];
return (
<Card>
<CardHeader>
<CardTitle className="text-base font-semibold">Request Outcomes Over Time</CardTitle>
</CardHeader>
<CardContent>
<div className="h-80 min-h-[280px]">
{chartData.length > 0 ? (
<BarChart
data={chartData}
index="date"
categories={["passed", "blocked"]}
colors={["green", "red"]}
valueFormatter={(v) => v.toLocaleString()}
yAxisWidth={48}
showLegend={true}
stack={true}
className="h-full"
/>
) : (
<div className="flex items-center justify-center h-full text-sm text-gray-500">
No chart data for this period
</div>
)}
</div>
</CardContent>
</Card>
);
}

View file

@ -1,64 +0,0 @@
import React from "react";
import { describe, it, expect, vi } from "vitest";
import { screen } from "@testing-library/react";
import { renderWithProviders } from "../../../../../tests/test-utils";
import { ScoreChart } from "./ScoreChart";
vi.mock("@tremor/react", async (importOriginal) => {
const actual = await importOriginal<typeof import("@tremor/react")>();
// Re-apply the global Button/Tooltip overrides from tests/setupTests.ts. A file-level
// vi.mock fully replaces the setup-level mock, so without this the real Tremor Button
// leaks through and its useTooltip(300) schedules a native setTimeout that can fire
// post-teardown -> "window is not defined".
return {
...actual,
BarChart: ({ data, categories }: { data: any[]; categories: string[] }) => (
<div data-testid="bar-chart">
{data.map((d, i) => (
<span key={i}>
{d.date}: {categories.map((c) => `${c}=${d[c]}`).join(", ")}
</span>
))}
</div>
),
Button: React.forwardRef<HTMLButtonElement, any>(({ children, ...props }, ref) => (
<button {...props} ref={ref}>
{children}
</button>
)),
Tooltip: ({ children }: { children?: React.ReactNode }) => <>{children}</>,
};
});
describe("ScoreChart", () => {
it("should render the title", () => {
renderWithProviders(<ScoreChart />);
expect(screen.getByText("Request Outcomes Over Time")).toBeInTheDocument();
});
it("should show empty state when no data is provided", () => {
renderWithProviders(<ScoreChart />);
expect(screen.getByText("No chart data for this period")).toBeInTheDocument();
});
it("should show empty state when data is an empty array", () => {
renderWithProviders(<ScoreChart data={[]} />);
expect(screen.getByText("No chart data for this period")).toBeInTheDocument();
});
it("should render the chart when data is provided", () => {
const data = [
{ date: "2026-03-01", passed: 10, blocked: 2 },
{ date: "2026-03-02", passed: 15, blocked: 1 },
];
renderWithProviders(<ScoreChart data={data} />);
expect(screen.queryByText("No chart data for this period")).not.toBeInTheDocument();
expect(screen.getByText(/2026-03-01/)).toBeInTheDocument();
expect(screen.getByText(/2026-03-02/)).toBeInTheDocument();
});
});

View file

@ -1,38 +0,0 @@
import { BarChart, Card, Title } from "@tremor/react";
import React from "react";
/**
* Overview chart: Request Outcomes Over Time (passed vs blocked).
* Uses Tremor BarChart with stacked data. Data from usage/overview API (chart array).
*/
interface ScoreChartProps {
data?: Array<{ date: string; passed: number; blocked: number }>;
}
export function ScoreChart({ data }: ScoreChartProps) {
const chartData = data && data.length > 0 ? data : [];
return (
<Card className="bg-white border border-gray-200">
<Title className="text-base font-semibold text-gray-900 mb-4">Request Outcomes Over Time</Title>
<div className="h-80 min-h-[280px]">
{chartData.length > 0 ? (
<BarChart
data={chartData}
index="date"
categories={["passed", "blocked"]}
colors={["green", "red"]}
valueFormatter={(v) => v.toLocaleString()}
yAxisWidth={48}
showLegend={true}
stack={true}
/>
) : (
<div className="flex items-center justify-center h-full text-sm text-gray-500">
No chart data for this period
</div>
)}
</div>
</Card>
);
}

View file

@ -1,6 +1,6 @@
"use client";
import GuardrailsMonitorView from "./components/GuardrailsMonitorView";
import GuardrailsMonitorView from "./_components/GuardrailsMonitorView";
import useAuthorized from "@/app/(dashboard)/hooks/useAuthorized";
export default function GuardrailsMonitor() {

View file

@ -1,6 +1,6 @@
"use client";
import { MemoryView } from "./components/MemoryView";
import { MemoryView } from "./_components/MemoryView";
import { DeprecationBanner } from "@/components/DeprecationBanner";
import useAuthorized from "@/app/(dashboard)/hooks/useAuthorized";

View file

@ -14,9 +14,9 @@ import {
import React, { useState, useEffect } from "react";
import ViewUserSpend from "./view_user_spend";
import { ProxySettings } from "./user_dashboard";
import UsageDatePicker from "./shared/usage_date_picker";
import ViewUserSpend from "@/components/view_user_spend";
import { ProxySettings } from "@/components/user_dashboard";
import UsageDatePicker from "@/components/shared/usage_date_picker";
import {
Grid,
Col,
@ -48,8 +48,8 @@ import {
adminGlobalActivity,
adminGlobalActivityPerModel,
getProxyUISettings,
} from "./networking";
import TopKeyView from "./UsagePage/components/EntityUsage/TopKeyView";
} from "@/components/networking";
import TopKeyView from "@/components/UsagePage/components/EntityUsage/TopKeyView";
import { MoneyCell } from "@/components/shared/table_cells";
import { formatNumberWithCommas } from "@/utils/dataUtils";

View file

@ -1,6 +1,6 @@
"use client";
import Usage from "@/components/usage";
import Usage from "./_components/usage";
import { DeprecationBanner } from "@/components/DeprecationBanner";
import useAuthorized from "@/app/(dashboard)/hooks/useAuthorized";

View file

@ -3,11 +3,11 @@ import { render } from "@testing-library/react";
import React from "react";
import { describe, expect, it, vi } from "vitest";
vi.mock("./vector_store_management/VectorStoreSelector", () => ({
vi.mock("@/components/vector_store_management/VectorStoreSelector", () => ({
__esModule: true,
default: () => null,
}));
vi.mock("./mcp_server_management/MCPServerSelector", () => ({
vi.mock("@/components/mcp_server_management/MCPServerSelector", () => ({
__esModule: true,
default: () => null,
}));

View file

@ -28,16 +28,21 @@ import { Form, Input, Modal, Select as Select2, Tooltip } from "antd";
import { useQueryClient } from "@tanstack/react-query";
import React, { useState } from "react";
import { DateCell, IdCell, MoneyCell } from "@/components/shared/table_cells";
import DeleteResourceModal from "./common_components/DeleteResourceModal";
import TableIconActionButton from "./common_components/IconActionButton/TableIconActionButtons/TableIconActionButton";
import { getModelDisplayName } from "./key_team_helpers/fetch_available_models_team_key";
import MCPServerSelector from "./mcp_server_management/MCPServerSelector";
import { ModelSelect } from "./ModelSelect/ModelSelect";
import NotificationsManager from "./molecules/notifications_manager";
import { Organization, organizationCreateCall, organizationDeleteCall, organizationListCall } from "./networking";
import OrganizationInfoView from "./organization/organization_view";
import NumericalInput from "./shared/numerical_input";
import VectorStoreSelector from "./vector_store_management/VectorStoreSelector";
import DeleteResourceModal from "@/components/common_components/DeleteResourceModal";
import TableIconActionButton from "@/components/common_components/IconActionButton/TableIconActionButtons/TableIconActionButton";
import { getModelDisplayName } from "@/components/key_team_helpers/fetch_available_models_team_key";
import MCPServerSelector from "@/components/mcp_server_management/MCPServerSelector";
import { ModelSelect } from "@/components/ModelSelect/ModelSelect";
import NotificationsManager from "@/components/molecules/notifications_manager";
import {
Organization,
organizationCreateCall,
organizationDeleteCall,
organizationListCall,
} from "@/components/networking";
import OrganizationInfoView from "@/components/organization/organization_view";
import NumericalInput from "@/components/shared/numerical_input";
import VectorStoreSelector from "@/components/vector_store_management/VectorStoreSelector";
interface OrganizationsTableProps {
userRole: string;

View file

@ -1,6 +1,6 @@
"use client";
import OrganizationsTable from "@/components/organizations";
import OrganizationsTable from "./_components/organizations";
import useAuthorized from "@/app/(dashboard)/hooks/useAuthorized";
export default function OrganizationsPage() {

View file

@ -11,7 +11,7 @@ import {
} from "@ant-design/icons";
import { Button, Input, Modal, Select, Spin, Tabs } from "antd";
import React, { useCallback, useEffect, useState } from "react";
import CodeBlock from "@/app/(dashboard)/api-reference/components/CodeBlock";
import CodeBlock from "@/components/CodeBlock";
import NotificationsManager from "@/components/molecules/notifications_manager";
import {
keyCreateCall,

Some files were not shown because too many files have changed in this diff Show more