mirror of
https://github.com/BerriAI/litellm.git
synced 2026-09-24 00:52:24 +00:00
Merge remote-tracking branch 'origin/main' into litellm_cost_shard_batches_realtime
This commit is contained in:
commit
ebad623079
8 changed files with 487 additions and 41 deletions
|
|
@ -427,7 +427,7 @@ class LLMCallSpanData:
|
|||
# plain ``.get`` — no repeated ``isinstance`` guards.
|
||||
raw_response: Final = payload.get("response")
|
||||
response: Final = cast(Mapping[str, object], raw_response if isinstance(raw_response, dict) else {})
|
||||
choices_out: Final = _dicts(response.get("choices")) or _responses_choices(response)
|
||||
choices_out: Final = _dicts(response.get("choices")) or _responses_choices(response) or _ocr_choices(response)
|
||||
# ``finish_reasons`` is metadata, not content, so derive it from
|
||||
# ``choices_out`` before gating. The raw message/choice bodies are only
|
||||
# retained when content capture is enabled (see ``capture_span_content``);
|
||||
|
|
@ -752,6 +752,22 @@ def _responses_choices(response: Mapping[str, object]) -> tuple[_Choice, ...]:
|
|||
return (choice,)
|
||||
|
||||
|
||||
def _ocr_choices(response: Mapping[str, object]) -> tuple[_Choice, ...]:
|
||||
markdowns: Final = tuple(
|
||||
text for page in _dicts(response.get("pages")) if (text := as_str(page.get("markdown"))) is not None
|
||||
)
|
||||
if not markdowns:
|
||||
return ()
|
||||
message: Final[_AssistantMessage] = {
|
||||
"role": "assistant",
|
||||
"content": "\n\n".join(markdowns),
|
||||
"refusal": None,
|
||||
"tool_calls": None,
|
||||
}
|
||||
choice: Final[_Choice] = {"message": message, "finish_reason": None}
|
||||
return (choice,)
|
||||
|
||||
|
||||
def _responses_parts_text(parts: tuple[Mapping[str, object], ...], part_type: str, field: str) -> str | None:
|
||||
texts: Final = tuple(
|
||||
text for part in parts if part.get("type") == part_type if (text := as_str(part.get(field))) is not None
|
||||
|
|
|
|||
|
|
@ -2500,9 +2500,8 @@ class MCPServerManager:
|
|||
# Filter blank scopes (e.g. YAML ``scopes: [""]``) the same way the DB-build path does, so
|
||||
# an all-blank list normalizes to None rather than a ``("",)`` tuple that skips the
|
||||
# entra_obo fail-closed scope precondition and POSTs an empty scope to the IdP.
|
||||
resolved_scopes = self._extract_scopes(server_config.get("scopes")) or (
|
||||
gated_oauth_metadata.scopes if gated_oauth_metadata else None
|
||||
)
|
||||
configured_scopes = self._extract_scopes(server_config.get("scopes"))
|
||||
resolved_scopes = configured_scopes or (gated_oauth_metadata.scopes if gated_oauth_metadata else None)
|
||||
resolved_authorization_url = manual_authorization_url or (
|
||||
gated_oauth_metadata.authorization_url if gated_oauth_metadata else None
|
||||
)
|
||||
|
|
@ -2579,6 +2578,7 @@ class MCPServerManager:
|
|||
client_secret=server_config.get("client_secret", None),
|
||||
oauth2_flow=self._explicit_oauth2_flow(config_oauth2_flow),
|
||||
scopes=resolved_scopes,
|
||||
configured_scopes=tuple(configured_scopes) if configured_scopes else None,
|
||||
issuer=effective_issuer,
|
||||
issuer_is_anchored=use_issuer_anchor,
|
||||
authorization_url=resolved_authorization_url,
|
||||
|
|
@ -3055,6 +3055,18 @@ class MCPServerManager:
|
|||
if scopes_value is not None:
|
||||
scopes = self._extract_scopes(scopes_value)
|
||||
|
||||
stored_scopes: Final[object] = credentials_dict.get("scopes") if credentials_dict else None
|
||||
scopes_as_objects: Final = (
|
||||
cast(Sequence[object], stored_scopes) # cast-ok: list shape validated below
|
||||
if isinstance(stored_scopes, list)
|
||||
else ()
|
||||
)
|
||||
configured_scopes: Final = (
|
||||
tuple(scope for scope in scopes_as_objects if isinstance(scope, str))
|
||||
if scopes_as_objects and all(isinstance(scope, str) and scope for scope in scopes_as_objects)
|
||||
else None
|
||||
)
|
||||
|
||||
name_for_prefix: Final = mcp_server.alias or mcp_server.server_name or mcp_server.server_id
|
||||
|
||||
mcp_info: Final[MCPInfo] = _mcp_info.copy()
|
||||
|
|
@ -3129,6 +3141,7 @@ class MCPServerManager:
|
|||
client_secret=client_secret_value or getattr(mcp_server, "client_secret", None),
|
||||
oauth2_flow=self._explicit_oauth2_flow(getattr(mcp_server, "oauth2_flow", None)),
|
||||
scopes=resolved_scopes,
|
||||
configured_scopes=configured_scopes,
|
||||
issuer=effective_issuer,
|
||||
issuer_is_anchored=use_issuer_anchor,
|
||||
authorization_url=manual_authorization_url or getattr(gated_oauth_metadata, "authorization_url", None),
|
||||
|
|
@ -7094,6 +7107,11 @@ class MCPServerManager:
|
|||
spec_path=server.spec_path,
|
||||
transport=server.transport,
|
||||
auth_type=server.auth_type,
|
||||
credentials=(
|
||||
{"scopes": list(server.configured_scopes)} # mutable-ok: MCPCredentials requires a JSON-array list
|
||||
if server.configured_scopes
|
||||
else None
|
||||
),
|
||||
created_at=server.created_at,
|
||||
updated_at=server.updated_at,
|
||||
teams=[],
|
||||
|
|
|
|||
|
|
@ -22,7 +22,14 @@ import os
|
|||
from collections.abc import Iterable, Mapping, Sequence
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from typing import TYPE_CHECKING, Annotated, Final, Literal, Protocol
|
||||
from typing import (
|
||||
TYPE_CHECKING,
|
||||
Annotated,
|
||||
Final,
|
||||
Literal,
|
||||
Protocol,
|
||||
cast, # noqa: TID251 # validated JSON values need explicit narrowing
|
||||
)
|
||||
|
||||
from fastapi import (
|
||||
APIRouter,
|
||||
|
|
@ -628,8 +635,8 @@ if MCP_AVAILABLE:
|
|||
|
||||
def _preserved_admin_config_credentials(
|
||||
credentials: "MCPCredentials | str | None",
|
||||
) -> "dict[str, str] | None":
|
||||
"""Keep only the non-secret admin-config keys, which are stored unencrypted so they lift out
|
||||
) -> "dict[str, str | list[str]] | None": # mutable-ok: API response payload
|
||||
"""Keep non-secret admin-config keys and scopes, which are stored unencrypted so they lift out
|
||||
as plaintext; every secret and minted-token key is dropped.
|
||||
|
||||
Total over every stored shape: a dict is read directly, a JSON-object string is parsed, and
|
||||
|
|
@ -639,15 +646,30 @@ if MCP_AVAILABLE:
|
|||
parsed: object = credentials
|
||||
if isinstance(credentials, str):
|
||||
try:
|
||||
parsed = json.loads(credentials)
|
||||
parsed = cast(object, json.loads(credentials)) # cast-ok: JSON parse result is validated below
|
||||
except (ValueError, TypeError):
|
||||
return None
|
||||
if not isinstance(parsed, dict):
|
||||
return None
|
||||
preserved: Final = {
|
||||
key: value
|
||||
for key in MCP_ADMIN_CONFIG_CREDENTIAL_KEYS
|
||||
if isinstance((value := parsed.get(key)), str) and value
|
||||
parsed_credentials: Final = cast(Mapping[str, object], parsed) # cast-ok: dict shape validated above
|
||||
scopes: Final[object] = parsed_credentials.get("scopes")
|
||||
scopes_as_objects: Final = (
|
||||
cast(Sequence[object], scopes) # cast-ok: list shape validated above
|
||||
if isinstance(scopes, list)
|
||||
else ()
|
||||
)
|
||||
preserved_scopes: Final = (
|
||||
{"scopes": cast(list[str], scopes_as_objects)} # cast-ok: every scope is validated below
|
||||
if scopes_as_objects and all(isinstance(scope, str) and scope for scope in scopes_as_objects)
|
||||
else {}
|
||||
)
|
||||
preserved: Final = { # mutable-ok: API response payload
|
||||
**{
|
||||
key: value
|
||||
for key in MCP_ADMIN_CONFIG_CREDENTIAL_KEYS
|
||||
if isinstance((value := parsed_credentials.get(key)), str) and value
|
||||
},
|
||||
**preserved_scopes,
|
||||
}
|
||||
return preserved or None
|
||||
|
||||
|
|
@ -827,7 +849,9 @@ if MCP_AVAILABLE:
|
|||
if not credentials:
|
||||
return False
|
||||
as_dict: Final[dict[str, object]] = dict(credentials)
|
||||
return any(value for key, value in as_dict.items() if key not in MCP_ADMIN_CONFIG_CREDENTIAL_KEYS)
|
||||
return any(
|
||||
value for key, value in as_dict.items() if key not in MCP_ADMIN_CONFIG_CREDENTIAL_KEYS and key != "scopes"
|
||||
)
|
||||
|
||||
def _inherit_credentials_from_existing_server(
|
||||
payload: NewMCPServerRequest,
|
||||
|
|
|
|||
|
|
@ -99,6 +99,7 @@ class MCPServer(BaseModel):
|
|||
configured_authorization_url: str | None = None
|
||||
configured_token_url: str | None = None
|
||||
configured_registration_url: str | None = None
|
||||
configured_scopes: tuple[str, ...] | None = None
|
||||
# How the gateway authenticates to the upstream token endpoint. When
|
||||
# "client_secret_basic" the credentials go in an HTTP Basic Authorization
|
||||
# header (omitted from the body); None defaults to "client_secret_post".
|
||||
|
|
|
|||
|
|
@ -872,6 +872,45 @@ def test_chat_choices_win_over_a_responses_output_list():
|
|||
assert data.finish_reasons == ("stop",)
|
||||
|
||||
|
||||
def _ocr_payload(pages: list[object]):
|
||||
return _sample_payload(
|
||||
call_type="aocr",
|
||||
custom_llm_provider="mistral",
|
||||
model="mistral-ocr-latest",
|
||||
messages=None,
|
||||
response={"object": "ocr", "model": "mistral-ocr-latest", "pages": pages, "usage_info": {"pages_processed": 2}},
|
||||
)
|
||||
|
||||
|
||||
def test_ocr_pages_become_one_assistant_choice_joined_in_page_order():
|
||||
data = LLMCallSpanData.from_standard_logging_payload(
|
||||
_ocr_payload([{"index": 0, "markdown": "# Invoice"}, {"index": 1, "markdown": "Total: 42"}]),
|
||||
capture_content=True,
|
||||
)
|
||||
|
||||
assert data.choices_out == (
|
||||
{
|
||||
"message": {"role": "assistant", "content": "# Invoice\n\nTotal: 42", "refusal": None, "tool_calls": None},
|
||||
"finish_reason": None,
|
||||
},
|
||||
)
|
||||
assert data.finish_reasons == ()
|
||||
|
||||
|
||||
def test_ocr_output_follows_the_content_capture_gate():
|
||||
data = LLMCallSpanData.from_standard_logging_payload(_ocr_payload([{"index": 0, "markdown": "# Invoice"}]))
|
||||
|
||||
assert data.choices_out == ()
|
||||
|
||||
|
||||
def test_ocr_pages_without_markdown_stay_empty():
|
||||
data = LLMCallSpanData.from_standard_logging_payload(
|
||||
_ocr_payload([{"index": 0, "images": []}, "not-a-page"]), capture_content=True
|
||||
)
|
||||
|
||||
assert data.choices_out == ()
|
||||
|
||||
|
||||
def test_request_identity_prefers_canonical_team_keys():
|
||||
from litellm.integrations.otel.model.payloads import RequestIdentity
|
||||
|
||||
|
|
|
|||
|
|
@ -227,6 +227,28 @@ def test_langfuse_mapper_renders_a_responses_api_call_from_the_standard_logging_
|
|||
assert attrs["langfuse.observation.type"] == "generation"
|
||||
|
||||
|
||||
def test_langfuse_mapper_renders_an_ocr_call_with_the_page_markdown_as_output():
|
||||
payload = {
|
||||
"call_type": "aocr",
|
||||
"custom_llm_provider": "mistral",
|
||||
"model": "mistral-ocr-latest",
|
||||
"messages": None,
|
||||
"response": {
|
||||
"object": "ocr",
|
||||
"model": "mistral-ocr-latest",
|
||||
"pages": [{"index": 0, "markdown": "# Invoice"}, {"index": 1, "markdown": "Total: 42"}],
|
||||
"usage_info": {"pages_processed": 2},
|
||||
},
|
||||
}
|
||||
data = LLMCallSpanData.from_standard_logging_payload(payload, capture_content=True)
|
||||
attrs = LangfuseMapper().map(data)
|
||||
|
||||
assert json.loads(attrs["langfuse.observation.output"]) == [
|
||||
{"role": "assistant", "content": "# Invoice\n\nTotal: 42", "refusal": None, "tool_calls": None}
|
||||
]
|
||||
assert attrs["langfuse.observation.type"] == "generation"
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Weave
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
|
|
|||
|
|
@ -65,6 +65,8 @@ from litellm.types.llms.custom_http import httpxSpecialProvider
|
|||
from litellm.types.mcp import MCPAuth, MCPAuthType
|
||||
from litellm.types.mcp_server.mcp_server_manager import MCPOAuthMetadata, MCPServer
|
||||
from litellm.caching.caching import DualCache
|
||||
from litellm.caching.llm_caching_handler import LLMClientCache
|
||||
from litellm.llms.custom_httpx.http_handler import AsyncHTTPHandler
|
||||
import litellm
|
||||
from litellm.integrations.custom_guardrail import CustomGuardrail
|
||||
from litellm.proxy.utils import ProxyLogging
|
||||
|
|
@ -10483,11 +10485,16 @@ def test_build_mcp_server_table_carries_oauth2_flow():
|
|||
transport=MCPTransport.http,
|
||||
auth_type=MCPAuth.oauth2,
|
||||
oauth2_flow="client_credentials",
|
||||
client_id="client-123",
|
||||
client_secret="secret-xyz",
|
||||
scopes=["scope:a", "scope:b"],
|
||||
configured_scopes=("scope:a", "scope:b"),
|
||||
)
|
||||
|
||||
table = manager._build_mcp_server_table(server)
|
||||
|
||||
assert table.oauth2_flow == "client_credentials"
|
||||
assert table.credentials == {"scopes": ["scope:a", "scope:b"]}
|
||||
|
||||
|
||||
def test_build_mcp_server_table_carries_null_oauth2_flow():
|
||||
|
|
@ -10511,6 +10518,226 @@ def test_build_mcp_server_table_carries_null_oauth2_flow():
|
|||
assert table.oauth2_flow is None
|
||||
|
||||
|
||||
async def _mock_oauth_discovery(
|
||||
respx_mock: MockRouter,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
*,
|
||||
server_url: str,
|
||||
scopes: list[str],
|
||||
) -> None:
|
||||
resource_metadata_url: Final[str] = "https://up.example.com/.well-known/oauth-protected-resource"
|
||||
authorization_server_url: Final[str] = "https://up.example.com"
|
||||
authorization_metadata_url: Final[str] = f"{authorization_server_url}/.well-known/oauth-authorization-server"
|
||||
respx_mock.get(server_url).respond(
|
||||
status_code=401,
|
||||
headers={"WWW-Authenticate": f'Bearer resource_metadata="{resource_metadata_url}"'},
|
||||
)
|
||||
respx_mock.get(resource_metadata_url).respond(
|
||||
json={"authorization_servers": [authorization_server_url], "scopes_supported": scopes}
|
||||
)
|
||||
respx_mock.get(authorization_metadata_url).respond(
|
||||
json={
|
||||
"issuer": authorization_server_url,
|
||||
"authorization_endpoint": f"{authorization_server_url}/authorize",
|
||||
"token_endpoint": f"{authorization_server_url}/token",
|
||||
}
|
||||
)
|
||||
clients: Final[LLMClientCache] = LLMClientCache()
|
||||
monkeypatch.setattr(litellm, "in_memory_llm_clients_cache", clients)
|
||||
http_handler: Final[AsyncHTTPHandler] = AsyncHTTPHandler()
|
||||
await http_handler.client.aclose()
|
||||
http_handler.client = httpx.AsyncClient(transport=httpx.MockTransport(respx_mock.async_handler))
|
||||
http_handler._owns_client = True
|
||||
cache_key: Final[str] = f"async_httpx_clienttimeout_{MCP_METADATA_TIMEOUT}{httpxSpecialProvider.MCP.value}"
|
||||
clients.set_cache(cache_key, http_handler)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize("discovery_on_startup", [True, False])
|
||||
async def test_management_view_serves_configured_scopes_not_discovered_ones_from_db(
|
||||
discovery_on_startup: bool,
|
||||
respx_mock: MockRouter,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
row: Final[LiteLLM_MCPServerTable] = LiteLLM_MCPServerTable(
|
||||
server_id="discovered-scopes-db",
|
||||
alias="discovered_scopes_db",
|
||||
url="https://up.example.com/mcp",
|
||||
transport=MCPTransport.http,
|
||||
auth_type=MCPAuth.oauth2,
|
||||
oauth2_flow="authorization_code",
|
||||
created_at=datetime.now(),
|
||||
updated_at=datetime.now(),
|
||||
)
|
||||
await _mock_oauth_discovery(respx_mock, monkeypatch, server_url=row.url or "", scopes=["discovered.read"])
|
||||
env: Final[dict[str, str]] = {"LITELLM_MCP_OAUTH_DISCOVERY_ON_STARTUP": "1"} if discovery_on_startup else {}
|
||||
with patch.dict(os.environ, env, clear=True):
|
||||
manager: Final[MCPServerManager] = MCPServerManager()
|
||||
built: Final[MCPServer] = await manager.build_mcp_server_from_table(row, credentials_are_encrypted=False)
|
||||
manager.registry[built.server_id] = built
|
||||
resolved: Final[MCPServer] = await manager.ensure_oauth_metadata_discovered(built)
|
||||
|
||||
assert resolved.scopes == ["discovered.read"]
|
||||
view: Final[LiteLLM_MCPServerTable] = manager._build_mcp_server_table(resolved)
|
||||
assert view.credentials is None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize(
|
||||
("stored_scopes", "runtime_scopes"),
|
||||
[
|
||||
(None, ["openid"]),
|
||||
([], ["openid"]),
|
||||
([""], ["openid"]),
|
||||
(["read", ""], ["read"]),
|
||||
(["read", 7], ["read"]),
|
||||
("read", ["read"]),
|
||||
],
|
||||
)
|
||||
async def test_management_view_omits_invalid_or_absent_db_scopes(
|
||||
stored_scopes: list[str | int] | str | None,
|
||||
runtime_scopes: list[str],
|
||||
respx_mock: MockRouter,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
row: Final[LiteLLM_MCPServerTable] = LiteLLM_MCPServerTable.model_construct(
|
||||
server_id="empty-scopes-db",
|
||||
alias="empty_scopes_db",
|
||||
url="https://up.example.com/mcp",
|
||||
transport=MCPTransport.http,
|
||||
auth_type=MCPAuth.oauth2,
|
||||
oauth2_flow="authorization_code",
|
||||
credentials=json.dumps({"scopes": stored_scopes}),
|
||||
created_at=datetime.now(),
|
||||
updated_at=datetime.now(),
|
||||
)
|
||||
await _mock_oauth_discovery(respx_mock, monkeypatch, server_url=row.url or "", scopes=["openid"])
|
||||
env: Final[dict[str, str]] = {"LITELLM_MCP_OAUTH_DISCOVERY_ON_STARTUP": "1"}
|
||||
with patch.dict(os.environ, env, clear=True):
|
||||
manager: Final[MCPServerManager] = MCPServerManager()
|
||||
built: Final[MCPServer] = await manager.build_mcp_server_from_table(row, credentials_are_encrypted=False)
|
||||
|
||||
assert built.scopes == runtime_scopes
|
||||
view: Final[LiteLLM_MCPServerTable] = manager._build_mcp_server_table(built)
|
||||
assert view.credentials is None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize("discovery_on_startup", [True, False])
|
||||
@pytest.mark.parametrize(
|
||||
("stored_scopes", "runtime_scopes"),
|
||||
[
|
||||
(["calendar.read"], ["calendar.read"]),
|
||||
([" "], ["discovered.read"]),
|
||||
(["read", " "], ["read"]),
|
||||
(["read", "read"], ["read", "read"]),
|
||||
],
|
||||
)
|
||||
async def test_management_view_serves_explicitly_configured_scopes_from_db(
|
||||
stored_scopes: list[str],
|
||||
runtime_scopes: list[str],
|
||||
discovery_on_startup: bool,
|
||||
respx_mock: MockRouter,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
row: Final[LiteLLM_MCPServerTable] = LiteLLM_MCPServerTable(
|
||||
server_id="configured-scopes-db",
|
||||
alias="configured_scopes_db",
|
||||
url="https://up.example.com/mcp",
|
||||
transport=MCPTransport.http,
|
||||
auth_type=MCPAuth.oauth2,
|
||||
oauth2_flow="authorization_code",
|
||||
credentials={"scopes": stored_scopes},
|
||||
created_at=datetime.now(),
|
||||
updated_at=datetime.now(),
|
||||
)
|
||||
await _mock_oauth_discovery(respx_mock, monkeypatch, server_url=row.url or "", scopes=["discovered.read"])
|
||||
env: Final[dict[str, str]] = {"LITELLM_MCP_OAUTH_DISCOVERY_ON_STARTUP": "1"} if discovery_on_startup else {}
|
||||
with patch.dict(os.environ, env, clear=True):
|
||||
manager: Final[MCPServerManager] = MCPServerManager()
|
||||
built: Final[MCPServer] = await manager.build_mcp_server_from_table(row, credentials_are_encrypted=False)
|
||||
manager.registry[built.server_id] = built
|
||||
resolved: Final[MCPServer] = await manager.ensure_oauth_metadata_discovered(built)
|
||||
|
||||
assert resolved.scopes == runtime_scopes
|
||||
view: Final[LiteLLM_MCPServerTable] = manager._build_mcp_server_table(resolved)
|
||||
assert view.credentials == {"scopes": stored_scopes}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize("discovery_on_startup", [True, False])
|
||||
@pytest.mark.parametrize(
|
||||
("configured_scopes", "expected_view_scopes"),
|
||||
[
|
||||
(None, None),
|
||||
(["calendar.read"], ["calendar.read"]),
|
||||
([" "], None),
|
||||
([""], None),
|
||||
(["calendar.read", " "], ["calendar.read"]),
|
||||
],
|
||||
)
|
||||
async def test_management_view_scopes_follow_yaml_config_not_discovery(
|
||||
configured_scopes: list[str] | None,
|
||||
expected_view_scopes: list[str] | None,
|
||||
discovery_on_startup: bool,
|
||||
respx_mock: MockRouter,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
config: Final[dict[str, dict[str, object]]] = {
|
||||
"yamlscopes": {
|
||||
"url": "https://up.example.com/mcp",
|
||||
"transport": MCPTransport.http,
|
||||
"auth_type": MCPAuth.oauth2,
|
||||
"oauth2_flow": "authorization_code",
|
||||
"client_id": "cid",
|
||||
"client_secret": "csec",
|
||||
**({"scopes": configured_scopes} if configured_scopes is not None else {}),
|
||||
}
|
||||
}
|
||||
await _mock_oauth_discovery(
|
||||
respx_mock, monkeypatch, server_url="https://up.example.com/mcp", scopes=["discovered.read"]
|
||||
)
|
||||
env: Final[dict[str, str]] = {"LITELLM_MCP_OAUTH_DISCOVERY_ON_STARTUP": "1"} if discovery_on_startup else {}
|
||||
with patch.dict(os.environ, env, clear=True):
|
||||
manager: Final[MCPServerManager] = MCPServerManager()
|
||||
await manager.load_servers_from_config(config)
|
||||
server: Final[MCPServer] = next(iter(manager.config_mcp_servers.values()))
|
||||
resolved: Final[MCPServer] = await manager.ensure_oauth_metadata_discovered(server)
|
||||
|
||||
assert resolved.scopes == (expected_view_scopes or ["discovered.read"])
|
||||
view: Final[LiteLLM_MCPServerTable] = manager._build_mcp_server_table(resolved)
|
||||
assert view.credentials == ({"scopes": expected_view_scopes} if expected_view_scopes else None)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_lazy_yaml_discovery_keeps_configured_scopes_out_of_the_management_view(
|
||||
respx_mock: MockRouter,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
config: Final[dict[str, dict[str, object]]] = {
|
||||
"lazyyamlscopes": {
|
||||
"url": "https://up.example.com/mcp",
|
||||
"transport": MCPTransport.http,
|
||||
"auth_type": MCPAuth.oauth2,
|
||||
"oauth2_flow": "authorization_code",
|
||||
"client_id": "cid",
|
||||
"client_secret": "csec",
|
||||
}
|
||||
}
|
||||
await _mock_oauth_discovery(
|
||||
respx_mock, monkeypatch, server_url="https://up.example.com/mcp", scopes=["discovered.read"]
|
||||
)
|
||||
with patch.dict(os.environ, {}, clear=True):
|
||||
manager: Final[MCPServerManager] = MCPServerManager()
|
||||
await manager.load_servers_from_config(config)
|
||||
server: Final[MCPServer] = next(iter(manager.config_mcp_servers.values()))
|
||||
resolved: Final[MCPServer] = await manager.ensure_oauth_metadata_discovered(server)
|
||||
|
||||
assert resolved.scopes == ["discovered.read"]
|
||||
view: Final[LiteLLM_MCPServerTable] = manager._build_mcp_server_table(resolved)
|
||||
assert view.credentials is None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_resolve_toolset_tool_permissions_single_db_fetch_across_checks():
|
||||
"""The server-level and tool-level permission primitives each resolve the
|
||||
|
|
|
|||
|
|
@ -6,7 +6,7 @@ import logging
|
|||
from contextlib import ExitStack
|
||||
from datetime import datetime, timedelta
|
||||
from types import SimpleNamespace
|
||||
from typing import List, Optional
|
||||
from typing import List, Optional, cast
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
|
@ -29,7 +29,7 @@ from litellm.proxy._types import (
|
|||
UpdateMCPServerRequest,
|
||||
UserAPIKeyAuth,
|
||||
)
|
||||
from litellm.types.mcp import MCPAuth
|
||||
from litellm.types.mcp import MCPAuth, MCPCredentials
|
||||
from litellm.types.mcp_server.mcp_server_manager import MCPServer
|
||||
|
||||
|
||||
|
|
@ -834,6 +834,83 @@ class TestListMCPServers:
|
|||
mock_health_result.health_check_error = None
|
||||
mock_user_auth = generate_mock_user_api_key_auth(user_role=LitellmUserRoles.PROXY_ADMIN)
|
||||
|
||||
with (
|
||||
patch( # test-quality-ok: endpoint test must patch module globals
|
||||
"litellm.proxy.management_endpoints.mcp_management_endpoints.get_prisma_client_or_throw",
|
||||
return_value=MagicMock(),
|
||||
),
|
||||
patch( # test-quality-ok: endpoint test must patch module globals
|
||||
"litellm.proxy.management_endpoints.mcp_management_endpoints.get_mcp_server",
|
||||
AsyncMock(return_value=mock_server),
|
||||
),
|
||||
patch( # test-quality-ok: endpoint test must patch module globals
|
||||
"litellm.proxy.management_endpoints.mcp_management_endpoints.global_mcp_server_manager.health_check_server",
|
||||
AsyncMock(return_value=mock_health_result),
|
||||
),
|
||||
patch( # test-quality-ok: endpoint test must patch module globals
|
||||
"litellm.proxy.management_endpoints.mcp_management_endpoints._user_has_admin_view",
|
||||
return_value=True,
|
||||
),
|
||||
):
|
||||
from litellm.proxy.management_endpoints.mcp_management_endpoints import (
|
||||
fetch_mcp_server,
|
||||
)
|
||||
|
||||
result = await fetch_mcp_server(
|
||||
request=_make_mock_request(),
|
||||
server_id="server-mal",
|
||||
user_api_key_dict=mock_user_auth,
|
||||
)
|
||||
|
||||
assert result.credentials == expected
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"stored_credentials, expected",
|
||||
[
|
||||
(
|
||||
{
|
||||
"client_id": "cid",
|
||||
"client_secret": "csecret",
|
||||
"scopes": ["read", "write"],
|
||||
"upstream_token_header": "esb-oauth",
|
||||
},
|
||||
{"scopes": ["read", "write"], "upstream_token_header": "esb-oauth"},
|
||||
),
|
||||
(
|
||||
'{"client_id": "cid", "client_secret": "csecret", "scopes": ["read", "write"], '
|
||||
'"upstream_token_header": "esb-oauth"}',
|
||||
{"scopes": ["read", "write"], "upstream_token_header": "esb-oauth"},
|
||||
),
|
||||
(
|
||||
{"client_id": "cid", "client_secret": "csecret", "scopes": []},
|
||||
None,
|
||||
),
|
||||
(
|
||||
'{"client_id": "cid", "client_secret": "csecret", "scopes": []}',
|
||||
None,
|
||||
),
|
||||
(
|
||||
{"client_id": "cid", "client_secret": "csecret", "scopes": ["read", ""]},
|
||||
None,
|
||||
),
|
||||
(
|
||||
{"client_id": "cid", "client_secret": "csecret", "scopes": "read"},
|
||||
None,
|
||||
),
|
||||
],
|
||||
)
|
||||
@pytest.mark.asyncio
|
||||
async def test_fetch_single_mcp_server_preserves_valid_oauth_scopes(
|
||||
self, stored_credentials: object, expected: object
|
||||
):
|
||||
mock_server = generate_mock_mcp_server_db_record(server_id="server-scopes", alias="Scopes")
|
||||
mock_server.credentials = cast(MCPCredentials, stored_credentials)
|
||||
mock_health_result = generate_mock_mcp_server_db_record(server_id="server-scopes", alias="Scopes")
|
||||
mock_health_result.status = "healthy"
|
||||
mock_health_result.last_health_check = datetime.now()
|
||||
mock_health_result.health_check_error = None
|
||||
mock_user_auth = generate_mock_user_api_key_auth(user_role=LitellmUserRoles.PROXY_ADMIN)
|
||||
|
||||
with (
|
||||
patch(
|
||||
"litellm.proxy.management_endpoints.mcp_management_endpoints.get_prisma_client_or_throw",
|
||||
|
|
@ -858,11 +935,11 @@ class TestListMCPServers:
|
|||
|
||||
result = await fetch_mcp_server(
|
||||
request=_make_mock_request(),
|
||||
server_id="server-mal",
|
||||
server_id="server-scopes",
|
||||
user_api_key_dict=mock_user_auth,
|
||||
)
|
||||
|
||||
assert result.credentials == expected
|
||||
assert result.credentials == expected
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_fetch_single_mcp_server_strips_upstream_resource_for_non_admin(self):
|
||||
|
|
@ -1635,14 +1712,26 @@ class TestTemporaryMCPSessionEndpoints:
|
|||
|
||||
return _inherit_credentials_from_existing_server(payload)
|
||||
|
||||
def test_admin_config_alone_does_not_suppress_credential_inheritance(self):
|
||||
"""The edit form round-trips upstream_resource, which is admin config rather than a credential.
|
||||
Treating the blob as "credentials supplied" left the Authorize session with no declared app on
|
||||
the exact path where this knob is configured."""
|
||||
updated = self._inherit_with({"upstream_resource": "api://audience"})
|
||||
@pytest.mark.parametrize(
|
||||
"credentials",
|
||||
[
|
||||
{"upstream_resource": "api://audience"},
|
||||
{"scopes": ["scope:a", "scope:b"]},
|
||||
{"scopes": ["scope:edited"], "upstream_resource": "api://audience"},
|
||||
{"scopes": ["scope:edited"], "upstream_token_header": "esb-oauth"},
|
||||
{"scopes": []},
|
||||
{"scopes": None},
|
||||
],
|
||||
)
|
||||
def test_admin_config_alone_does_not_suppress_credential_inheritance(self, credentials: MCPCredentials):
|
||||
updated = self._inherit_with(credentials, scopes=["scope:stored"])
|
||||
|
||||
assert updated.credentials["client_id"] == "client-123"
|
||||
assert updated.credentials["client_secret"] == "secret-xyz"
|
||||
assert updated.credentials == {
|
||||
"client_id": "client-123",
|
||||
"client_secret": "secret-xyz",
|
||||
"scopes": ["scope:stored"],
|
||||
**credentials,
|
||||
}
|
||||
|
||||
def test_upstream_token_header_is_inherited_like_other_admin_config(self):
|
||||
"""It is admin config rather than a credential, so a session server derived from an existing
|
||||
|
|
@ -1661,11 +1750,18 @@ class TestTemporaryMCPSessionEndpoints:
|
|||
assert updated.credentials["client_secret"] == "secret-xyz"
|
||||
assert updated.credentials["upstream_token_header"] == "esb-oauth"
|
||||
|
||||
def test_supplied_credential_still_wins_over_inheritance(self):
|
||||
"""A caller that supplies a real credential keeps it; inheritance must not overwrite it."""
|
||||
updated = self._inherit_with({"auth_value": "caller-token"})
|
||||
@pytest.mark.parametrize(
|
||||
"credentials",
|
||||
[
|
||||
{"auth_value": "caller-token"},
|
||||
{"client_id": "caller-client", "scopes": ["scope:edited"]},
|
||||
{"client_secret": "caller-secret", "scopes": ["scope:edited"]},
|
||||
],
|
||||
)
|
||||
def test_supplied_credential_still_wins_over_inheritance(self, credentials: MCPCredentials):
|
||||
updated = self._inherit_with(credentials)
|
||||
|
||||
assert updated.credentials == {"auth_value": "caller-token"}
|
||||
assert updated.credentials == credentials
|
||||
|
||||
def test_inheritance_carries_upstream_resource_to_the_session_server(self):
|
||||
"""Without this the temporary server omits the resource indicator and the Authorize leg it
|
||||
|
|
@ -2339,7 +2435,7 @@ class TestTemporaryMCPSessionEndpoints:
|
|||
"client_secret": "client-secret",
|
||||
"scopes": ["scope1"],
|
||||
}
|
||||
assert response.credentials is None
|
||||
assert response.credentials == {"scopes": ["scope1"]}
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_add_session_mcp_server_rejects_non_admins(self):
|
||||
|
|
@ -4494,13 +4590,9 @@ class TestMCPApprovalWorkflow:
|
|||
assert result.total == 1
|
||||
assert result.pending_review == 1
|
||||
|
||||
@pytest.mark.parametrize("allowed_routes", [None, [], ["llm_api_routes"], ["mcp_routes"]])
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_submissions_sanitizes_for_view_only_admin(self):
|
||||
"""PROXY_ADMIN_VIEW_ONLY reviewing the submission queue must go through
|
||||
the non-admin sanitizer that fetch/list endpoints use: url,
|
||||
static_headers, env, env_vars, and credentials are all dropped. A
|
||||
mutation swapping the gate back to the old partial-blank pattern (which
|
||||
left url/static_headers/env and env-var names intact) would fail this."""
|
||||
async def test_get_submissions_sanitizes_for_view_only_admin(self, allowed_routes: list[str] | None):
|
||||
from litellm.proxy._types import MCPSubmissionsSummary
|
||||
from litellm.proxy.management_endpoints.mcp_management_endpoints import (
|
||||
get_mcp_server_submissions,
|
||||
|
|
@ -4508,6 +4600,7 @@ class TestMCPApprovalWorkflow:
|
|||
|
||||
item = _leaky_list_server()
|
||||
item.approval_status = "pending_review"
|
||||
item.spec_path = "https://example.com/spec.json?key=private"
|
||||
summary = MCPSubmissionsSummary(total=1, pending_review=1, active=0, rejected=0, items=[item])
|
||||
|
||||
with (
|
||||
|
|
@ -4521,11 +4614,15 @@ class TestMCPApprovalWorkflow:
|
|||
),
|
||||
):
|
||||
result = await get_mcp_server_submissions(
|
||||
user_api_key_dict=generate_mock_user_api_key_auth(user_role=LitellmUserRoles.PROXY_ADMIN_VIEW_ONLY),
|
||||
user_api_key_dict=UserAPIKeyAuth(
|
||||
user_role=LitellmUserRoles.PROXY_ADMIN_VIEW_ONLY, allowed_routes=allowed_routes
|
||||
),
|
||||
)
|
||||
|
||||
assert (result.total, result.pending_review, result.active, result.rejected) == (1, 1, 0, 0)
|
||||
assert len(result.items) == 1
|
||||
sanitized = result.items[0]
|
||||
assert sanitized.spec_path is None
|
||||
assert sanitized.url is None
|
||||
assert sanitized.static_headers is None
|
||||
assert sanitized.env == {}
|
||||
|
|
@ -4536,11 +4633,9 @@ class TestMCPApprovalWorkflow:
|
|||
assert item.url == "https://leaky.example.com/mcp?api_key=sk-embedded-in-url"
|
||||
assert item.static_headers == {"Authorization": "Bearer sk-secret-header"}
|
||||
|
||||
@pytest.mark.parametrize("allowed_routes", [None, [], ["llm_api_routes"], ["mcp_routes"]])
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_submissions_full_admin_still_sees_secrets(self):
|
||||
"""The view-only redaction must not over-redact for a full PROXY_ADMIN,
|
||||
who needs url/static_headers/env/env_vars to review the pending
|
||||
submission. Only the explicit credentials field is cleared."""
|
||||
async def test_get_submissions_full_admin_preserves_review_fields(self, allowed_routes: list[str] | None):
|
||||
from litellm.proxy._types import MCPSubmissionsSummary
|
||||
from litellm.proxy.management_endpoints.mcp_management_endpoints import (
|
||||
get_mcp_server_submissions,
|
||||
|
|
@ -4548,6 +4643,7 @@ class TestMCPApprovalWorkflow:
|
|||
|
||||
item = _leaky_list_server()
|
||||
item.approval_status = "pending_review"
|
||||
item.spec_path = "https://example.com/spec.json?key=private"
|
||||
summary = MCPSubmissionsSummary(total=1, pending_review=1, active=0, rejected=0, items=[item])
|
||||
|
||||
with (
|
||||
|
|
@ -4561,11 +4657,14 @@ class TestMCPApprovalWorkflow:
|
|||
),
|
||||
):
|
||||
result = await get_mcp_server_submissions(
|
||||
user_api_key_dict=generate_mock_user_api_key_auth(user_role=LitellmUserRoles.PROXY_ADMIN),
|
||||
user_api_key_dict=UserAPIKeyAuth(user_role=LitellmUserRoles.PROXY_ADMIN, allowed_routes=allowed_routes),
|
||||
)
|
||||
|
||||
assert (result.total, result.pending_review, result.active, result.rejected) == (1, 1, 0, 0)
|
||||
assert len(result.items) == 1
|
||||
raw = result.items[0]
|
||||
assert raw.spec_path == item.spec_path
|
||||
assert raw.approval_status == "pending_review"
|
||||
assert raw.url == "https://leaky.example.com/mcp?api_key=sk-embedded-in-url"
|
||||
assert raw.static_headers == {"Authorization": "Bearer sk-secret-header"}
|
||||
assert raw.env == {"UPSTREAM_TOKEN": "sk-secret-env"}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue