mirror of
https://github.com/BerriAI/litellm.git
synced 2026-09-13 23:11:40 +00:00
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:
commit
115694dc0e
140 changed files with 4007 additions and 521 deletions
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
|
|
|
|||
|
|
@ -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``.
|
||||
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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
|
||||
),
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
),
|
||||
|
|
|
|||
163
tests/e2e/llm_translation/test_cache_control.py
Normal file
163
tests/e2e/llm_translation/test_cache_control.py
Normal 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)
|
||||
|
|
@ -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})"
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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."""
|
||||
|
||||
|
|
|
|||
|
|
@ -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:])
|
||||
|
|
|
|||
224
tests/test_litellm/llms/openai_like/test_meta_provider.py
Normal file
224
tests/test_litellm/llms/openai_like/test_meta_provider.py
Normal 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
|
||||
|
|
@ -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(),
|
||||
|
|
|
|||
|
|
@ -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):
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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():
|
||||
"""
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
63
tests/test_litellm/test_muse_spark_1_1_model_metadata.py
Normal file
63
tests/test_litellm/test_muse_spark_1_1_model_metadata.py
Normal 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"
|
||||
)
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
221
ui/litellm-dashboard/package-lock.json
generated
221
ui/litellm-dashboard/package-lock.json
generated
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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() {
|
||||
|
|
|
|||
|
|
@ -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>,
|
||||
}));
|
||||
|
||||
|
|
@ -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;
|
||||
|
||||
|
|
@ -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";
|
||||
|
||||
|
|
|
|||
|
|
@ -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>,
|
||||
}));
|
||||
|
|
@ -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: {
|
||||
|
|
@ -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";
|
||||
|
|
|
|||
|
|
@ -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() {
|
||||
|
|
|
|||
|
|
@ -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() {
|
||||
|
|
|
|||
|
|
@ -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>,
|
||||
}));
|
||||
|
||||
|
|
|
|||
|
|
@ -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("");
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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() {
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
|
||||
|
|
@ -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";
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}));
|
||||
|
|
@ -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;
|
||||
|
|
@ -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() {
|
||||
|
|
|
|||
|
|
@ -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
Loading…
Add table
Reference in a new issue