mirror of
https://github.com/BerriAI/litellm.git
synced 2026-09-15 23:31:29 +00:00
chore: merge litellm_internal_staging
This commit is contained in:
commit
aaeb26313b
30 changed files with 994 additions and 217 deletions
|
|
@ -36,7 +36,7 @@ RUN uv venv --python python && \
|
|||
"opentelemetry-api==1.33.1" \
|
||||
"opentelemetry-sdk==1.33.1" \
|
||||
"opentelemetry-exporter-otlp==1.33.1" \
|
||||
"ddtrace==2.19.0" \
|
||||
"ddtrace==4.11.0" \
|
||||
"sentry-sdk==2.21.0" \
|
||||
"mangum==0.17.0" \
|
||||
"azure-ai-contentsafety==1.0.0" \
|
||||
|
|
|
|||
|
|
@ -10,7 +10,7 @@ from typing import TYPE_CHECKING, Any, Optional, Union
|
|||
from litellm.secret_managers.main import get_secret_bool
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from ddtrace.tracer import Tracer as DD_TRACER
|
||||
from ddtrace.trace import Tracer as DD_TRACER
|
||||
else:
|
||||
DD_TRACER = Any
|
||||
|
||||
|
|
|
|||
|
|
@ -186,6 +186,61 @@ _UPSTREAM_OAUTH_DISCOVERY_AUTH_TYPES: tuple[MCPAuth, ...] = (
|
|||
)
|
||||
|
||||
|
||||
def _blank_to_none(value: str | None) -> str | None:
|
||||
"""Collapse an absent, empty, or whitespace-only string to ``None``.
|
||||
|
||||
OAuth endpoint fields are consumed by truthiness-based merges (``row or discovered``) and by the
|
||||
corroboration gate. A whitespace-only value is truthy to ``or`` but is not a usable endpoint, so
|
||||
without this the merge would keep the blank value for redirects while the gate treats it as
|
||||
unpinned and backfills the other fields, yielding a broken half-discovered config. Normalizing
|
||||
the pinned fields once, at each build entry point, gives every downstream consumer a single
|
||||
notion of "blank" so those code paths cannot disagree.
|
||||
"""
|
||||
if not isinstance(value, str):
|
||||
return None
|
||||
return value.strip() or None
|
||||
|
||||
|
||||
def _normalized_authorize_endpoint(url: str) -> str:
|
||||
"""Compare authorize endpoints on scheme, host, and path only. The default port is elided and
|
||||
the host is lowercased so ``https://IDP.example.com:443/authorize/`` and
|
||||
``https://idp.example.com/authorize`` are the same identity; query and trailing slash are not."""
|
||||
parsed = urlparse(url)
|
||||
scheme = parsed.scheme.lower()
|
||||
host = (parsed.hostname or "").lower()
|
||||
default_port = {"https": 443, "http": 80}.get(scheme)
|
||||
try:
|
||||
port = parsed.port
|
||||
except ValueError:
|
||||
port = None
|
||||
authority = host if port is None or port == default_port else f"{host}:{port}"
|
||||
return f"{scheme}://{authority}{parsed.path.rstrip('/')}"
|
||||
|
||||
|
||||
def _endpoints_corroborate_authorization_url(
|
||||
source_authorization_url: str | None,
|
||||
trusted_authorization_url: str | None,
|
||||
) -> bool:
|
||||
"""Whether a source's ``token_url``/``registration_url`` may be paired with a trusted authorize
|
||||
endpoint. This is the single trust rule for adopting OAuth endpoints from any non-manual source.
|
||||
|
||||
Discovery is rooted at the MCP resource (RFC 9728), so a compromised upstream can advertise an
|
||||
attacker-run authorization server. When ``authorization_url`` is admin-pinned, pairing it with a
|
||||
``token_url`` from a different source is the RFC 9700 authorization-server mix-up: the user signs
|
||||
in at the trusted authorize endpoint while the gateway redeems the code, with the stored client
|
||||
secret and PKCE verifier, at the attacker's token endpoint. Endpoints are trustworthy together
|
||||
only when they share an authorization server, so a source's endpoints are adopted only when the
|
||||
same source advertised an ``authorization_endpoint`` matching the pinned value. With no pinned
|
||||
value (``trusted_authorization_url is None``) there is nothing to protect: the authorize endpoint
|
||||
comes from the same source as the token endpoint, so they corroborate each other by construction.
|
||||
"""
|
||||
if not (trusted_authorization_url and trusted_authorization_url.strip()):
|
||||
return True
|
||||
return bool(source_authorization_url) and _normalized_authorize_endpoint(
|
||||
source_authorization_url
|
||||
) == _normalized_authorize_endpoint(trusted_authorization_url)
|
||||
|
||||
|
||||
def _carry_forward_resolved_oauth_endpoints(new_server: MCPServer, previous_server: MCPServer | None) -> None:
|
||||
"""Keep the last known good OAuth endpoints when a rebuild's re-discovery comes back empty.
|
||||
|
||||
|
|
@ -193,26 +248,82 @@ def _carry_forward_resolved_oauth_endpoints(new_server: MCPServer, previous_serv
|
|||
during re-discovery downgrades a working server (``authorization_url`` set) to a broken one
|
||||
(``None``, /authorize 400s) with no configuration change. Mirrors the ``short_prefix``
|
||||
carry-forward. Skipped when the server's ``url`` or ``auth_type`` changed, since the previous
|
||||
endpoints may then belong to a different upstream. ``registration_url`` IS carried here even
|
||||
though ``_persist_discovered_oauth_endpoints`` refuses to write it to the row: carrying only
|
||||
restores the same in-memory value the previous build already ran with, while persisting it
|
||||
would flip ``_dcr_bridge_relays_client_registration`` (which keys off the stored column) for
|
||||
dcr_bridge servers that never had one configured.
|
||||
endpoints may then belong to a different upstream. ``registration_url`` IS carried even though
|
||||
``_persist_discovered_oauth_endpoints`` refuses to write it to the row: carrying only restores
|
||||
the same in-memory value the previous build already ran with, while persisting it would flip
|
||||
``_dcr_bridge_relays_client_registration`` (which keys off the stored column) for dcr_bridge
|
||||
servers that never had one configured.
|
||||
|
||||
Carry-forward is a non-manual endpoint source, so the same trust rule as discovery applies: the
|
||||
previous ``token_url``/``registration_url``/``scopes`` are carried only when the previous
|
||||
``authorization_url`` corroborates the authorize endpoint this build will use, i.e. when the
|
||||
incoming build has no pinned authorize endpoint (``None`` -> we adopt the previous one too, a
|
||||
consistent group) or pins the same one. An admin re-pointing ``authorization_url`` to a different
|
||||
server must not keep serving the old server's token endpoint or granted scopes.
|
||||
"""
|
||||
if previous_server is None:
|
||||
return
|
||||
if previous_server.url != new_server.url or previous_server.auth_type != new_server.auth_type:
|
||||
return
|
||||
may_carry = _endpoints_corroborate_authorization_url(
|
||||
previous_server.authorization_url, new_server.authorization_url
|
||||
)
|
||||
if new_server.authorization_url is None and previous_server.authorization_url:
|
||||
new_server.authorization_url = previous_server.authorization_url
|
||||
if new_server.token_url is None and previous_server.token_url:
|
||||
if may_carry and new_server.token_url is None and previous_server.token_url:
|
||||
new_server.token_url = previous_server.token_url
|
||||
if new_server.registration_url is None and previous_server.registration_url:
|
||||
if may_carry and new_server.registration_url is None and previous_server.registration_url:
|
||||
new_server.registration_url = previous_server.registration_url
|
||||
if not new_server.scopes and previous_server.scopes:
|
||||
if may_carry and not new_server.scopes and previous_server.scopes:
|
||||
new_server.scopes = previous_server.scopes
|
||||
|
||||
|
||||
def _restrict_discovery_to_corroborated_authorization_server(
|
||||
metadata: MCPOAuthMetadata | None,
|
||||
manual_authorization_url: str | None,
|
||||
server_identifier: str,
|
||||
is_dcr_bridge: bool,
|
||||
) -> MCPOAuthMetadata | None:
|
||||
"""Reject discovered token/registration endpoints a manually pinned authorize endpoint cannot
|
||||
vouch for (the RFC 9700 authorization-server mix-up).
|
||||
|
||||
Discovery is rooted at the MCP resource, so a compromised upstream can advertise an attacker
|
||||
``token_endpoint``: with ``authorization_url`` admin-pinned but ``token_url`` blank, the merge
|
||||
would pair the trusted authorize endpoint with that attacker token endpoint, and the gateway would
|
||||
post the authorization code and client secret there. So the discovered ``token_url`` and
|
||||
``registration_url`` are kept only if the document corroborates the pin (its
|
||||
``authorization_endpoint`` matches). ``scopes`` are deliberately NOT gated here: per the MCP
|
||||
authorization spec Scope Selection Strategy and RFC 9700 §2.3, the scopes a client requests are
|
||||
resource-driven (the WWW-Authenticate challenge or the RFC 9728 protected-resource
|
||||
``scopes_supported``), and scope inflation by a compromised resource is bounded by the
|
||||
authorization server and user consent (RFC 6749 §3.3), not by the client second-guessing the
|
||||
request. With no pin there is no trust anchor to protect, so discovery is returned as-is.
|
||||
"""
|
||||
if metadata is None or not (manual_authorization_url and manual_authorization_url.strip()):
|
||||
return metadata
|
||||
if _endpoints_corroborate_authorization_url(metadata.authorization_url, manual_authorization_url):
|
||||
return metadata
|
||||
if not metadata.token_url and not metadata.registration_url:
|
||||
return metadata
|
||||
bridge_note = (
|
||||
" The discovered registration_url is rejected with it, so this dcr_bridge server stays on the"
|
||||
" short-circuit registration arm."
|
||||
if is_dcr_bridge and metadata.registration_url
|
||||
else ""
|
||||
)
|
||||
verbose_logger.warning(
|
||||
"MCP OAuth discovery for server %s advertised authorization_endpoint %s, which does not match the "
|
||||
"manually configured authorization_url %s; rejecting the discovered token_url/registration_url so "
|
||||
"authorization codes and client credentials only follow the configured authorization server. "
|
||||
"Configure Token URL manually if the mismatch is intentional.%s",
|
||||
server_identifier,
|
||||
_normalized_authorize_endpoint(metadata.authorization_url) if metadata.authorization_url else "<absent>",
|
||||
_normalized_authorize_endpoint(manual_authorization_url),
|
||||
bridge_note,
|
||||
)
|
||||
return metadata.model_copy(update={"token_url": None, "registration_url": None})
|
||||
|
||||
|
||||
def invalidate_user_env_vars_cache(user_id: str, server_id: str) -> None:
|
||||
"""Drop a cached entry after the user stores or clears their env var values
|
||||
so the next request reads the fresh value instead of a stale one."""
|
||||
|
|
@ -1026,12 +1137,15 @@ class MCPServerManager:
|
|||
)
|
||||
|
||||
auth_type = server_config.get("auth_type", None)
|
||||
manual_authorization_url = _blank_to_none(server_config.get("authorization_url"))
|
||||
manual_token_url = _blank_to_none(server_config.get("token_url"))
|
||||
manual_registration_url = _blank_to_none(server_config.get("registration_url"))
|
||||
if server_url and (
|
||||
auth_type in _UPSTREAM_OAUTH_DISCOVERY_AUTH_TYPES
|
||||
or self._obo_needs_endpoint_discovery(
|
||||
auth_type,
|
||||
server_config.get("token_exchange_endpoint"),
|
||||
server_config.get("token_url"),
|
||||
manual_token_url,
|
||||
)
|
||||
):
|
||||
mcp_oauth_metadata = await self._descovery_metadata(
|
||||
|
|
@ -1041,20 +1155,29 @@ class MCPServerManager:
|
|||
else:
|
||||
mcp_oauth_metadata = None
|
||||
|
||||
gated_oauth_metadata = (
|
||||
_restrict_discovery_to_corroborated_authorization_server(
|
||||
mcp_oauth_metadata,
|
||||
manual_authorization_url,
|
||||
server_name or server_id,
|
||||
bool(server_config.get("dcr_bridge")),
|
||||
)
|
||||
if auth_type in _UPSTREAM_OAUTH_DISCOVERY_AUTH_TYPES
|
||||
else mcp_oauth_metadata
|
||||
)
|
||||
|
||||
# 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 (
|
||||
mcp_oauth_metadata.scopes if mcp_oauth_metadata else None
|
||||
gated_oauth_metadata.scopes if gated_oauth_metadata else None
|
||||
)
|
||||
resolved_authorization_url = server_config.get("authorization_url") or (
|
||||
mcp_oauth_metadata.authorization_url if mcp_oauth_metadata else None
|
||||
resolved_authorization_url = manual_authorization_url or (
|
||||
gated_oauth_metadata.authorization_url if gated_oauth_metadata else None
|
||||
)
|
||||
resolved_token_url = server_config.get("token_url") or (
|
||||
mcp_oauth_metadata.token_url if mcp_oauth_metadata else None
|
||||
)
|
||||
resolved_registration_url = server_config.get("registration_url") or (
|
||||
mcp_oauth_metadata.registration_url if mcp_oauth_metadata else None
|
||||
resolved_token_url = manual_token_url or (gated_oauth_metadata.token_url if gated_oauth_metadata else None)
|
||||
resolved_registration_url = manual_registration_url or (
|
||||
gated_oauth_metadata.registration_url if gated_oauth_metadata else None
|
||||
)
|
||||
|
||||
config_oauth2_flow = server_config.get("oauth2_flow", None)
|
||||
|
|
@ -1447,13 +1570,17 @@ class MCPServerManager:
|
|||
|
||||
auth_type = cast(MCPAuthType, mcp_server.auth_type)
|
||||
server_url = mcp_server.url
|
||||
manual_authorization_url = _blank_to_none(mcp_server.authorization_url)
|
||||
manual_token_url = _blank_to_none(mcp_server.token_url)
|
||||
manual_registration_url = _blank_to_none(mcp_server.registration_url)
|
||||
has_all_upstream_oauth_fields = bool(manual_authorization_url and manual_token_url and scopes)
|
||||
needs_discovery = bool(server_url) and (
|
||||
(auth_type in _UPSTREAM_OAUTH_DISCOVERY_AUTH_TYPES and not mcp_server.authorization_url)
|
||||
(auth_type in _UPSTREAM_OAUTH_DISCOVERY_AUTH_TYPES and not has_all_upstream_oauth_fields)
|
||||
or self._obo_needs_endpoint_discovery(
|
||||
auth_type,
|
||||
mcp_server.token_exchange_endpoint
|
||||
or (credentials_dict.get("token_exchange_endpoint") if credentials_dict else None),
|
||||
mcp_server.token_url,
|
||||
manual_token_url,
|
||||
)
|
||||
)
|
||||
mcp_oauth_metadata = (
|
||||
|
|
@ -1467,12 +1594,22 @@ class MCPServerManager:
|
|||
if needs_discovery and mcp_oauth_metadata is None:
|
||||
verbose_logger.warning(
|
||||
"MCP OAuth discovery yielded no metadata for server %s (%s); "
|
||||
"OAuth endpoints stay unresolved until a rebuild succeeds",
|
||||
"OAuth endpoints/scopes stay unresolved until a rebuild succeeds",
|
||||
mcp_server.server_id,
|
||||
server_url,
|
||||
)
|
||||
gated_oauth_metadata = (
|
||||
_restrict_discovery_to_corroborated_authorization_server(
|
||||
mcp_oauth_metadata,
|
||||
manual_authorization_url,
|
||||
mcp_server.server_id,
|
||||
bool(getattr(mcp_server, "dcr_bridge", None)),
|
||||
)
|
||||
if auth_type in _UPSTREAM_OAUTH_DISCOVERY_AUTH_TYPES
|
||||
else mcp_oauth_metadata
|
||||
)
|
||||
|
||||
resolved_scopes = scopes or (mcp_oauth_metadata.scopes if mcp_oauth_metadata else None)
|
||||
resolved_scopes = scopes or (gated_oauth_metadata.scopes if gated_oauth_metadata else None)
|
||||
|
||||
new_server = MCPServer(
|
||||
server_id=mcp_server.server_id,
|
||||
|
|
@ -1492,9 +1629,9 @@ 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,
|
||||
authorization_url=mcp_server.authorization_url or getattr(mcp_oauth_metadata, "authorization_url", None),
|
||||
token_url=mcp_server.token_url or getattr(mcp_oauth_metadata, "token_url", None),
|
||||
registration_url=mcp_server.registration_url or getattr(mcp_oauth_metadata, "registration_url", None),
|
||||
authorization_url=manual_authorization_url or getattr(gated_oauth_metadata, "authorization_url", None),
|
||||
token_url=manual_token_url or getattr(gated_oauth_metadata, "token_url", None),
|
||||
registration_url=manual_registration_url or getattr(gated_oauth_metadata, "registration_url", None),
|
||||
token_endpoint_auth_method=(
|
||||
credentials_dict.get("token_endpoint_auth_method") if credentials_dict else None
|
||||
),
|
||||
|
|
@ -1545,16 +1682,16 @@ class MCPServerManager:
|
|||
await self._persist_discovered_obo_token_url(
|
||||
server_id=mcp_server.server_id,
|
||||
auth_type=auth_type,
|
||||
existing_token_url=mcp_server.token_url,
|
||||
existing_token_url=manual_token_url,
|
||||
discovered_token_url=new_server.token_url,
|
||||
)
|
||||
await self._persist_discovered_oauth_endpoints(
|
||||
server_id=mcp_server.server_id,
|
||||
auth_type=auth_type,
|
||||
existing_authorization_url=mcp_server.authorization_url,
|
||||
existing_token_url=mcp_server.token_url,
|
||||
existing_authorization_url=manual_authorization_url,
|
||||
existing_token_url=manual_token_url,
|
||||
existing_scopes=scopes,
|
||||
metadata=mcp_oauth_metadata,
|
||||
metadata=gated_oauth_metadata,
|
||||
)
|
||||
return new_server
|
||||
|
||||
|
|
|
|||
|
|
@ -98,9 +98,9 @@ def _sanitize_user_api_key_auth(auth: Any) -> Any:
|
|||
return auth
|
||||
|
||||
|
||||
def _classifier_call_metadata(metadata: dict[str, Any] | None) -> dict[str, Any] | None:
|
||||
def _classifier_call_metadata(metadata: dict[str, Any] | None) -> dict[str, Any]:
|
||||
if not metadata:
|
||||
return metadata
|
||||
return {}
|
||||
return {
|
||||
k: _sanitize_user_api_key_auth(v) if k == "user_api_key_auth" else v
|
||||
for k, v in metadata.items()
|
||||
|
|
@ -763,8 +763,8 @@ class ComplexityRouter(CustomLogger):
|
|||
# embedding call. Forwarding it would let the embedding's cost callback finalize the
|
||||
# reservation, so the routed completion's own callback then skips incrementing the
|
||||
# key/team budget. Key/team attribution fields are preserved for spend logging.
|
||||
metadata = _classifier_call_metadata(request_kwargs.get("metadata")) or {}
|
||||
litellm_metadata = _classifier_call_metadata(request_kwargs.get("litellm_metadata")) or {}
|
||||
metadata = _classifier_call_metadata(request_kwargs.get("metadata"))
|
||||
litellm_metadata = _classifier_call_metadata(request_kwargs.get("litellm_metadata"))
|
||||
query_vector = (
|
||||
await encoder.aencode_queries([user_message], metadata=metadata, litellm_metadata=litellm_metadata)
|
||||
)[0]
|
||||
|
|
|
|||
|
|
@ -17,6 +17,12 @@ MCPInfo = Dict[str, Any]
|
|||
|
||||
class MCPOAuthMetadata(BaseModel):
|
||||
scopes: Optional[List[str]] = None
|
||||
"""Resource-driven scopes for the authorization request: the RFC 9728 protected-resource
|
||||
``scopes_supported``, or the ``scope`` from the WWW-Authenticate 401 challenge when the resource
|
||||
supplied one, else the authorization server's ``scopes_supported``. This is the scope value a
|
||||
client requests per the MCP authorization spec Scope Selection Strategy; scope minimization and
|
||||
inflation control are the authorization server's and user's job at consent (RFC 6749 §3.3), not
|
||||
the client's."""
|
||||
authorization_url: Optional[str] = None
|
||||
token_url: Optional[str] = None
|
||||
registration_url: Optional[str] = None
|
||||
|
|
|
|||
|
|
@ -129,8 +129,7 @@ proxy-runtime = [
|
|||
"opentelemetry-sdk==1.33.1",
|
||||
"opentelemetry-exporter-otlp==1.33.1",
|
||||
"opentelemetry-instrumentation-fastapi==0.54b1",
|
||||
"ddtrace>=2.19.0,<3.0; python_version < '3.14'",
|
||||
"ddtrace>=4.0.0,<5.0; python_version >= '3.14'",
|
||||
"ddtrace>=4.8.2,<5.0",
|
||||
"sentry-sdk>=2.21.0,<3.0",
|
||||
"mangum>=0.17.0,<1.0",
|
||||
"azure-ai-contentsafety>=1.0.0,<2.0",
|
||||
|
|
|
|||
|
|
@ -16,8 +16,8 @@ from pathlib import Path
|
|||
import pytest
|
||||
import yaml
|
||||
|
||||
REPO_ROOT = Path(__file__).resolve().parents[1]
|
||||
MANIFEST_PATH = REPO_ROOT / "manifest.yaml"
|
||||
SUITE_ROOT = Path(__file__).resolve().parents[1]
|
||||
MANIFEST_PATH = SUITE_ROOT / "manifest.yaml"
|
||||
|
||||
# The PRD's "Features in v0" section, in row order.
|
||||
EXPECTED_FEATURE_IDS = [
|
||||
|
|
@ -90,14 +90,14 @@ def test_manifest_every_feature_has_human_readable_name(manifest):
|
|||
|
||||
@pytest.mark.parametrize("feature_id", EXPECTED_FEATURE_IDS)
|
||||
def test_feature_directory_exists(feature_id):
|
||||
feature_dir = REPO_ROOT / feature_id
|
||||
feature_dir = SUITE_ROOT / feature_id
|
||||
assert feature_dir.is_dir(), f"missing feature directory: {feature_dir}"
|
||||
|
||||
|
||||
@pytest.mark.parametrize("feature_id", EXPECTED_FEATURE_IDS)
|
||||
@pytest.mark.parametrize("provider", EXPECTED_PROVIDERS)
|
||||
def test_per_provider_test_file_exists(feature_id, provider):
|
||||
test_file = REPO_ROOT / feature_id / f"test_{provider}.py"
|
||||
test_file = SUITE_ROOT / feature_id / f"test_{provider}.py"
|
||||
assert test_file.is_file(), f"missing per-provider test file: {test_file}"
|
||||
|
||||
|
||||
|
|
@ -106,7 +106,7 @@ def test_feature_directory_has_init_file(feature_id):
|
|||
"""Each feature directory needs an __init__.py so pytest collects
|
||||
the per-provider test files as a package — matches the layout
|
||||
established by `basic_messaging_non_streaming/`."""
|
||||
init_file = REPO_ROOT / feature_id / "__init__.py"
|
||||
init_file = SUITE_ROOT / feature_id / "__init__.py"
|
||||
assert init_file.is_file(), f"missing __init__.py: {init_file}"
|
||||
|
||||
|
||||
|
|
@ -117,7 +117,7 @@ def test_feature_directory_has_init_file(feature_id):
|
|||
# a broken post-v0 directory still fails CI.
|
||||
@pytest.mark.parametrize("feature_id", ALL_FEATURE_IDS)
|
||||
def test_every_manifest_feature_has_directory(feature_id):
|
||||
feature_dir = REPO_ROOT / feature_id
|
||||
feature_dir = SUITE_ROOT / feature_id
|
||||
assert feature_dir.is_dir(), (
|
||||
f"manifest declares {feature_id!r} but {feature_dir} is missing — "
|
||||
"feature_id MUST match its on-disk directory (see manifest.yaml header)."
|
||||
|
|
@ -126,7 +126,7 @@ def test_every_manifest_feature_has_directory(feature_id):
|
|||
|
||||
@pytest.mark.parametrize("feature_id", ALL_FEATURE_IDS)
|
||||
def test_every_manifest_feature_has_init_file(feature_id):
|
||||
init_file = REPO_ROOT / feature_id / "__init__.py"
|
||||
init_file = SUITE_ROOT / feature_id / "__init__.py"
|
||||
assert init_file.is_file(), f"missing __init__.py: {init_file}"
|
||||
|
||||
|
||||
|
|
@ -137,7 +137,7 @@ def test_every_manifest_feature_has_per_provider_test_file(feature_id, provider)
|
|||
backed by a per-provider test file. Without this check, a missing
|
||||
file silently becomes a `not_tested` cell in the published matrix
|
||||
rather than a CI failure surfacing the layout drift."""
|
||||
test_file = REPO_ROOT / feature_id / f"test_{provider}.py"
|
||||
test_file = SUITE_ROOT / feature_id / f"test_{provider}.py"
|
||||
assert test_file.is_file(), f"missing per-provider test file: {test_file}"
|
||||
|
||||
|
||||
|
|
@ -151,7 +151,7 @@ def test_per_provider_test_file_imports_and_parametrizes_three_models(
|
|||
use plain aliases or per-provider-suffixed aliases (e.g.
|
||||
`claude-opus-4-7-bedrock-invoke`), so we check for the tier
|
||||
substrings rather than exact alias names."""
|
||||
text = (REPO_ROOT / feature_id / f"test_{provider}.py").read_text()
|
||||
text = (SUITE_ROOT / feature_id / f"test_{provider}.py").read_text()
|
||||
for tier in ("haiku-4-5", "sonnet-4-6", "opus-4-7"):
|
||||
assert (
|
||||
tier in text
|
||||
|
|
@ -171,7 +171,7 @@ def test_azure_test_file_drives_the_proxy(feature_id):
|
|||
that wraps them — both shapes drive the proxy, and we don't want
|
||||
this layout pin to block legitimate de-duplication of test bodies.
|
||||
"""
|
||||
text = (REPO_ROOT / feature_id / "test_azure.py").read_text()
|
||||
text = (SUITE_ROOT / feature_id / "test_azure.py").read_text()
|
||||
assert "run_claude" in text or "run_basic_messaging_cell" in text, (
|
||||
f"{feature_id}/test_azure.py must drive the claude CLI via run_claude() "
|
||||
"or a shared helper that wraps it; the not_applicable stub was removed "
|
||||
|
|
|
|||
|
|
@ -17,6 +17,7 @@
|
|||
- {id: reliability.routing.cost_based.picks_lowest_cost, module: reliability, tier: P1, behavior: routing, variant: cost_based, assertions: [picks_lowest_cost], exercised_on: [chat_completions, messages], source: "router_strategy/lowest_cost.py", rationale: "Spend-aware routing"}
|
||||
- {id: reliability.routing.usage_based.picks_under_tpm, module: reliability, tier: P0, behavior: routing, variant: usage_based, assertions: [picks_under_tpm], exercised_on: [chat_completions, messages], source: "router_strategy/lowest_tpm_rpm_v2.py", rationale: "Routes to lowest-TPM deployment; prevents over-allocation"}
|
||||
- {id: reliability.routing.least_busy.picks_lowest_traffic, module: reliability, tier: P1, behavior: routing, variant: least_busy, assertions: [picks_lowest_traffic], exercised_on: [chat_completions, messages], source: "router_strategy/least_busy.py", rationale: "Fewest in-flight requests"}
|
||||
- {id: reliability.routing.complexity_llm_classifier.routes_by_llm_tier, module: reliability, tier: P1, behavior: routing, variant: complexity_llm_classifier, assertions: [routes_by_llm_tier], exercised_on: [chat_completions], source: "router_strategy/complexity_router/complexity_router.py", fail_before_fix: proven, rationale: "v2 auto-router LLM complexity classifier runs over the proxy and routes by semantic tier instead of silently crashing on absent litellm_metadata and falling back to heuristic scoring"}
|
||||
- {id: reliability.cache.exact.returns_cached, module: reliability, tier: P1, behavior: cache, variant: exact, assertions: [returns_cached], exercised_on: [chat_completions, messages, embeddings], source: "litellm/caching/caching.py", rationale: "Response cache returns cached on exact match"}
|
||||
- {id: reliability.cache.prompt_caching_model_select.returns_cached, module: reliability, tier: P1, behavior: cache, variant: prompt_caching_model_select, assertions: [returns_cached], exercised_on: [chat_completions], source: "router_utils/prompt_caching_cache.py", rationale: "Selects model supporting prompt caching for cacheable prefix"}
|
||||
- {id: reliability.circuit_breaker.redis.trips_then_recovers, module: reliability, tier: P0, behavior: circuit_breaker, variant: redis, assertions: [trips_then_recovers], exercised_on: [chat_completions, messages, embeddings], source: "litellm/caching/redis_cache.py:99", rationale: "Redis breaker CLOSED->OPEN->HALF_OPEN; guards all cache/rate-limit ops"}
|
||||
|
|
|
|||
|
|
@ -66,6 +66,23 @@ configs:
|
|||
model: openai/text-embedding-3-small
|
||||
api_key: os.environ/OPENAI_API_KEY
|
||||
|
||||
# v2 auto-router with the LLM complexity classifier. SIMPLE stays on the
|
||||
# openai backend; every higher tier routes to the anthropic backend, so the
|
||||
# served deployment (read back from the spend log's model) reveals whether
|
||||
# the LLM classifier actually ran or silently fell back to heuristic scoring.
|
||||
- model_name: complexity-smart-router
|
||||
litellm_params:
|
||||
model: auto_router/complexity_router
|
||||
complexity_router_config:
|
||||
classifier_type: llm
|
||||
classifier_llm_config:
|
||||
model: gpt-5.5
|
||||
tiers:
|
||||
SIMPLE: gpt-5.5
|
||||
MEDIUM: claude-haiku-4-5
|
||||
COMPLEX: claude-haiku-4-5
|
||||
REASONING: claude-haiku-4-5
|
||||
|
||||
services:
|
||||
litellm:
|
||||
image: ghcr.io/berriai/litellm:main-latest
|
||||
|
|
|
|||
20
tests/e2e/router/complexity_router_client.py
Normal file
20
tests/e2e/router/complexity_router_client.py
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
"""Client for the complexity auto-router e2e tests.
|
||||
|
||||
The suite drives the shared /chat/completions and spend-log reads on the Gateway,
|
||||
so this client only carries the Gateway the shared lifecycle needs for cleanup.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
|
||||
from e2e_gateway import Gateway, build_gateway
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class ComplexityRouterClient:
|
||||
gateway: Gateway
|
||||
|
||||
|
||||
def build_client() -> ComplexityRouterClient:
|
||||
return ComplexityRouterClient(gateway=build_gateway())
|
||||
15
tests/e2e/router/conftest.py
Normal file
15
tests/e2e/router/conftest.py
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
"""Router suite's `client` fixture.
|
||||
|
||||
The shared lifecycle (resources/scoped_key), proxy liveness skip, and e2e marker
|
||||
live in the parent tests/e2e/conftest.py. ComplexityRouterClient holds the shared
|
||||
Gateway, so the `resources` fixture cleans up keys this suite creates.
|
||||
"""
|
||||
|
||||
import pytest
|
||||
|
||||
from complexity_router_client import ComplexityRouterClient, build_client
|
||||
|
||||
|
||||
@pytest.fixture(scope="session")
|
||||
def client() -> ComplexityRouterClient:
|
||||
return build_client()
|
||||
62
tests/e2e/router/test_complexity_router_e2e.py
Normal file
62
tests/e2e/router/test_complexity_router_e2e.py
Normal file
|
|
@ -0,0 +1,62 @@
|
|||
"""Live e2e: the v2 auto-router's LLM complexity classifier actually runs over the
|
||||
proxy and drives routing, instead of silently crashing and falling back to the
|
||||
local heuristic scorer.
|
||||
|
||||
The regression this guards (complexity_router.py `_classifier_call_metadata`
|
||||
returning None when the request carries no `litellm_metadata`, which the classifier
|
||||
sub-call then fed into a `.update`, raising `'NoneType' object has no attribute
|
||||
'update'`) was invisible from the outside: the router caught the error and answered
|
||||
from heuristic scoring, so every request still returned 200. The only tell is which
|
||||
tier, and therefore which backend, served the request.
|
||||
|
||||
`complexity-smart-router` (see the inline config in docker-compose.yml) pins SIMPLE
|
||||
to the openai backend and every higher tier to the anthropic backend. "Is P equal
|
||||
to NP?" is lexically trivial, so the heuristic scorer lands it in SIMPLE (openai),
|
||||
but any competent LLM classifier reads it as a hard reasoning question and lands it
|
||||
above SIMPLE (anthropic). The served deployment is read back from the spend log's
|
||||
`model`, so anthropic proves the classifier ran and openai proves it silently fell
|
||||
back - the exact failure before the fix.
|
||||
"""
|
||||
|
||||
import pytest
|
||||
|
||||
from complexity_router_client import ComplexityRouterClient
|
||||
from e2e_http import unwrap
|
||||
from models import ChatBody, ChatMessage
|
||||
|
||||
pytestmark = pytest.mark.e2e
|
||||
|
||||
ROUTER_MODEL = "complexity-smart-router"
|
||||
# Lexically simple (heuristic -> SIMPLE) but a hard reasoning question (LLM -> above SIMPLE).
|
||||
LEXICALLY_SIMPLE_HARD_PROMPT = "Is P equal to NP?"
|
||||
# SIMPLE tier backend; served only when the classifier silently falls back to heuristic.
|
||||
HEURISTIC_TIER_MODEL = "openai/gpt-5.5"
|
||||
# MEDIUM/COMPLEX/REASONING tier backend; served only when the LLM classifier runs.
|
||||
LLM_TIER_MODEL = "anthropic/claude-haiku-4-5"
|
||||
|
||||
|
||||
class TestComplexityRouterLlmClassifier:
|
||||
@pytest.mark.covers("reliability.routing.complexity_llm_classifier.routes_by_llm_tier")
|
||||
def test_llm_classifier_runs_and_routes_by_semantic_tier(
|
||||
self, client: ComplexityRouterClient, scoped_key: str
|
||||
) -> None:
|
||||
chat = unwrap(
|
||||
client.gateway.chat(
|
||||
scoped_key,
|
||||
ChatBody(
|
||||
model=ROUTER_MODEL,
|
||||
messages=[ChatMessage(role="user", content=LEXICALLY_SIMPLE_HARD_PROMPT)],
|
||||
max_tokens=16,
|
||||
),
|
||||
)
|
||||
)
|
||||
assert chat.choices, f"router returned no choices: {chat}"
|
||||
|
||||
rows = client.gateway.poll_logs_for_key(scoped_key, min_rows=1)
|
||||
served = [row.model for row in rows]
|
||||
assert served == [LLM_TIER_MODEL], (
|
||||
f"expected the request to be served by {LLM_TIER_MODEL!r} (the higher-tier "
|
||||
f"backend the LLM classifier picks for a hard prompt), but the spend log shows "
|
||||
f"{served!r}. {HEURISTIC_TIER_MODEL!r} means the LLM classifier silently failed "
|
||||
f"and the router fell back to heuristic scoring (SIMPLE) - the pre-fix regression"
|
||||
)
|
||||
|
|
@ -356,6 +356,86 @@ class TestMCPServerManager:
|
|||
assert server.oauth2_flow == "authorization_code"
|
||||
assert server.needs_user_oauth_token is True
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_load_servers_from_config_rejects_uncorroborated_endpoints_but_keeps_resource_scopes(self):
|
||||
"""A yaml server with a manual authorization_url has the same config-time mix-up exposure as a
|
||||
DB row: a document advertising a different authorize endpoint has its token_url rejected. The
|
||||
resource-driven scopes are kept, because scope selection is resource-driven (MCP Scope
|
||||
Selection Strategy) and scope inflation is bounded by the authorization server at consent, not
|
||||
by dropping scopes when an endpoint mismatches."""
|
||||
manager = MCPServerManager()
|
||||
|
||||
metadata = MCPOAuthMetadata(
|
||||
authorization_url="https://attacker.example.com/authorize",
|
||||
token_url="https://attacker.example.com/token",
|
||||
scopes=["read", "admin"],
|
||||
)
|
||||
config = self._oauth2_config(
|
||||
oauth2_flow="authorization_code",
|
||||
authorization_url="https://idp.example.com/authorize",
|
||||
token_url=None,
|
||||
)
|
||||
with patch.object(manager, "_descovery_metadata", new=AsyncMock(return_value=metadata)):
|
||||
await manager.load_servers_from_config(config)
|
||||
|
||||
server = next(iter(manager.config_mcp_servers.values()))
|
||||
assert server.authorization_url == "https://idp.example.com/authorize"
|
||||
assert server.token_url is None
|
||||
assert server.scopes == ["read", "admin"]
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_load_servers_from_config_fills_token_url_when_metadata_corroborates_manual_authorization_url(self):
|
||||
"""Corroborated metadata keeps the self-heal on the config path: when the discovered document
|
||||
advertises the same authorize endpoint the admin pinned, its token_url fills the blank field
|
||||
and scopes come through resource-driven (the discovered document's resource-preferred scopes),
|
||||
not the authorization server's own capability list."""
|
||||
manager = MCPServerManager()
|
||||
|
||||
metadata = MCPOAuthMetadata(
|
||||
authorization_url="https://idp.example.com/authorize",
|
||||
token_url="https://idp.example.com/token",
|
||||
scopes=["read", "admin"],
|
||||
)
|
||||
config = self._oauth2_config(
|
||||
oauth2_flow="authorization_code",
|
||||
authorization_url="https://idp.example.com/authorize/",
|
||||
token_url=None,
|
||||
)
|
||||
with patch.object(manager, "_descovery_metadata", new=AsyncMock(return_value=metadata)):
|
||||
await manager.load_servers_from_config(config)
|
||||
|
||||
server = next(iter(manager.config_mcp_servers.values()))
|
||||
assert server.token_url == "https://idp.example.com/token"
|
||||
assert server.scopes == ["read", "admin"]
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize("blank_authorization_url", ["", " "])
|
||||
async def test_load_servers_from_config_blank_authorization_url_is_not_a_pin(self, blank_authorization_url):
|
||||
"""A blank authorization_url — empty or whitespace-only — is not a trust anchor, so discovery
|
||||
backfills the whole set (authorize endpoint, token_url, and its resource-preferred scopes)
|
||||
from the same chain, exactly as if the field had been omitted. The merge and the corroboration
|
||||
gate must agree that blank means unpinned; a whitespace value that the merge kept for redirects
|
||||
while the gate treated as unpinned would strand a broken half-discovered config."""
|
||||
manager = MCPServerManager()
|
||||
|
||||
metadata = MCPOAuthMetadata(
|
||||
authorization_url="https://idp.example.com/authorize",
|
||||
token_url="https://idp.example.com/token",
|
||||
scopes=["read"],
|
||||
)
|
||||
config = self._oauth2_config(
|
||||
oauth2_flow="authorization_code",
|
||||
authorization_url=blank_authorization_url,
|
||||
token_url=None,
|
||||
)
|
||||
with patch.object(manager, "_descovery_metadata", new=AsyncMock(return_value=metadata)):
|
||||
await manager.load_servers_from_config(config)
|
||||
|
||||
server = next(iter(manager.config_mcp_servers.values()))
|
||||
assert server.authorization_url == "https://idp.example.com/authorize"
|
||||
assert server.token_url == "https://idp.example.com/token"
|
||||
assert server.scopes == ["read"]
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_load_servers_from_config_non_oauth2_needs_no_flow(self):
|
||||
manager = MCPServerManager()
|
||||
|
|
@ -1026,7 +1106,6 @@ class TestMCPServerManager:
|
|||
"""The gateway's relayed authorize flow (used by the browser-only Authorize) needs the
|
||||
upstream's authorization_url on the registry entry, and these rows never persist one, so
|
||||
the DB build must discover it the same way oauth2 rows do."""
|
||||
from types import SimpleNamespace
|
||||
|
||||
manager = MCPServerManager()
|
||||
row = LiteLLM_MCPServerTable(
|
||||
|
|
@ -1053,6 +1132,181 @@ class TestMCPServerManager:
|
|||
assert built.authorization_url == "https://idp.example.com/authorize"
|
||||
assert built.token_url == "https://idp.example.com/token"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_build_from_table_backfills_resource_driven_scopes_for_pinned_authorization_url(self):
|
||||
"""When authorization_url is admin-pinned and corroborated, scopes backfill as the
|
||||
resource-driven value (the WWW-Authenticate challenge scope, else the RFC 9728
|
||||
protected-resource scopes_supported), per the MCP authorization spec Scope Selection Strategy.
|
||||
The client does not restrict scopes to the authorization server's own scopes_supported; scope
|
||||
minimization and inflation control are the authorization server's and user's job at consent
|
||||
(RFC 6749 §3.3)."""
|
||||
manager = MCPServerManager()
|
||||
row = LiteLLM_MCPServerTable(
|
||||
server_id="manual-auth-url-1",
|
||||
alias="manual_auth_url",
|
||||
description="manual authorization_url, blank scopes",
|
||||
url="https://up.example.com/mcp",
|
||||
transport=MCPTransport.http,
|
||||
auth_type=MCPAuth.oauth2,
|
||||
authorization_url="https://idp.example.com/authorize",
|
||||
created_at=datetime.now(),
|
||||
updated_at=datetime.now(),
|
||||
)
|
||||
|
||||
metadata = MCPOAuthMetadata(
|
||||
authorization_url="https://idp.example.com/authorize",
|
||||
token_url="https://idp.example.com/token",
|
||||
scopes=["read", "admin"],
|
||||
)
|
||||
with patch.object(manager, "_descovery_metadata", new=AsyncMock(return_value=metadata)) as mock_discovery:
|
||||
built = await manager.build_mcp_server_from_table(row, credentials_are_encrypted=False)
|
||||
|
||||
mock_discovery.assert_awaited_once()
|
||||
assert built.authorization_url == "https://idp.example.com/authorize"
|
||||
assert built.token_url == "https://idp.example.com/token"
|
||||
assert built.scopes == ["read", "admin"]
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_build_from_table_whitespace_authorization_url_is_not_a_pin(self):
|
||||
"""A whitespace-only authorization_url on the row must not be kept for redirects while the
|
||||
gate treats it as unpinned. It is normalized to unpinned everywhere, so the built server
|
||||
takes the discovered authorize endpoint, token_url, and scopes as one consistent group
|
||||
rather than serving the whitespace value with half-discovered fields."""
|
||||
manager = MCPServerManager()
|
||||
row = LiteLLM_MCPServerTable(
|
||||
server_id="whitespace-auth-url",
|
||||
alias="whitespace_auth_url",
|
||||
description="whitespace authorization_url is not a pin",
|
||||
url="https://up.example.com/mcp",
|
||||
transport=MCPTransport.http,
|
||||
auth_type=MCPAuth.oauth2,
|
||||
authorization_url=" ",
|
||||
created_at=datetime.now(),
|
||||
updated_at=datetime.now(),
|
||||
)
|
||||
|
||||
metadata = MCPOAuthMetadata(
|
||||
authorization_url="https://idp.example.com/authorize",
|
||||
token_url="https://idp.example.com/token",
|
||||
scopes=["read"],
|
||||
)
|
||||
with patch.object(manager, "_descovery_metadata", new=AsyncMock(return_value=metadata)):
|
||||
built = await manager.build_mcp_server_from_table(row, credentials_are_encrypted=False)
|
||||
|
||||
assert built.authorization_url == "https://idp.example.com/authorize"
|
||||
assert built.token_url == "https://idp.example.com/token"
|
||||
assert built.scopes == ["read"]
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_build_from_table_fills_endpoints_when_metadata_corroborates_manual_authorization_url(self):
|
||||
"""A discovered token_url is only trusted next to a manual authorization_url when the same
|
||||
metadata document advertises that authorize endpoint, and the comparison must tolerate
|
||||
formatting-only differences (host case, trailing slash, query params like ?prompt=consent)
|
||||
so hand-copied URLs still self-heal."""
|
||||
manager = MCPServerManager()
|
||||
row = LiteLLM_MCPServerTable(
|
||||
server_id="manual-auth-url-2",
|
||||
alias="manual_auth_url_match",
|
||||
description="manual authorization_url matching discovery, blank token_url",
|
||||
url="https://up.example.com/mcp",
|
||||
transport=MCPTransport.http,
|
||||
auth_type=MCPAuth.oauth2,
|
||||
authorization_url="https://IDP.example.com/authorize/?prompt=consent",
|
||||
created_at=datetime.now(),
|
||||
updated_at=datetime.now(),
|
||||
)
|
||||
|
||||
metadata = MCPOAuthMetadata(
|
||||
authorization_url="https://idp.example.com/authorize",
|
||||
token_url="https://idp.example.com/token",
|
||||
registration_url="https://idp.example.com/register",
|
||||
scopes=["read"],
|
||||
)
|
||||
with patch.object(manager, "_descovery_metadata", new=AsyncMock(return_value=metadata)):
|
||||
built = await manager.build_mcp_server_from_table(row, credentials_are_encrypted=False)
|
||||
|
||||
assert built.authorization_url == "https://IDP.example.com/authorize/?prompt=consent"
|
||||
assert built.token_url == "https://idp.example.com/token"
|
||||
assert built.registration_url == "https://idp.example.com/register"
|
||||
assert built.scopes == ["read"]
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize(
|
||||
"advertised_authorization_url",
|
||||
["https://attacker.example.com/authorize", None],
|
||||
)
|
||||
async def test_build_from_table_rejects_uncorroborated_endpoints_but_keeps_resource_scopes(
|
||||
self, advertised_authorization_url
|
||||
):
|
||||
"""Resource-rooted discovery lets a compromised upstream advertise its own authorization
|
||||
server. With a manual authorization_url pinned, a document that does not corroborate it has
|
||||
its token_url and registration_url dropped: accepting them would send the code, client secret,
|
||||
and PKCE verifier to the attacker (config-time RFC 9700 mix-up). The resource-driven scopes
|
||||
are kept, because scope selection is resource-driven (MCP Scope Selection Strategy) and scope
|
||||
inflation is bounded by the authorization server at consent (RFC 6749 §3.3), not by dropping
|
||||
scopes on an endpoint mismatch. Both the in-memory merge and the persisted metadata drop only
|
||||
the uncorroborated endpoints."""
|
||||
manager = MCPServerManager()
|
||||
row = LiteLLM_MCPServerTable(
|
||||
server_id="manual-auth-url-3",
|
||||
alias="manual_auth_url_mismatch",
|
||||
description="manual authorization_url, hostile discovery document",
|
||||
url="https://up.example.com/mcp",
|
||||
transport=MCPTransport.http,
|
||||
auth_type=MCPAuth.oauth2,
|
||||
authorization_url="https://idp.example.com/authorize",
|
||||
created_at=datetime.now(),
|
||||
updated_at=datetime.now(),
|
||||
)
|
||||
|
||||
metadata = MCPOAuthMetadata(
|
||||
authorization_url=advertised_authorization_url,
|
||||
token_url="https://attacker.example.com/token",
|
||||
registration_url="https://attacker.example.com/register",
|
||||
scopes=["read", "admin"],
|
||||
)
|
||||
with (
|
||||
patch.object(manager, "_descovery_metadata", new=AsyncMock(return_value=metadata)),
|
||||
patch.object(manager, "_persist_discovered_oauth_endpoints", new=AsyncMock()) as mock_persist,
|
||||
):
|
||||
built = await manager.build_mcp_server_from_table(row, credentials_are_encrypted=False)
|
||||
|
||||
assert built.authorization_url == "https://idp.example.com/authorize"
|
||||
assert built.token_url is None
|
||||
assert built.registration_url is None
|
||||
assert built.scopes == ["read", "admin"]
|
||||
persisted_metadata = mock_persist.await_args.kwargs["metadata"]
|
||||
assert persisted_metadata.token_url is None
|
||||
assert persisted_metadata.registration_url is None
|
||||
assert persisted_metadata.scopes == ["read", "admin"]
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_build_from_table_skips_discovery_when_all_upstream_oauth_fields_present(self):
|
||||
"""A fully hand-configured server (authorization_url, token_url, and scopes all set) has
|
||||
nothing left for discovery to fill, so the build must not fetch upstream metadata."""
|
||||
manager = MCPServerManager()
|
||||
row = LiteLLM_MCPServerTable(
|
||||
server_id="fully-manual-1",
|
||||
alias="fully_manual",
|
||||
description="all upstream oauth fields set by the admin",
|
||||
url="https://up.example.com/mcp",
|
||||
transport=MCPTransport.http,
|
||||
auth_type=MCPAuth.oauth2,
|
||||
authorization_url="https://idp.example.com/manual-authorize",
|
||||
token_url="https://idp.example.com/manual-token",
|
||||
credentials={"scopes": ["calendar.read"]},
|
||||
created_at=datetime.now(),
|
||||
updated_at=datetime.now(),
|
||||
)
|
||||
|
||||
with patch.object(manager, "_descovery_metadata", new=AsyncMock(return_value=None)) as mock_discovery:
|
||||
built = await manager.build_mcp_server_from_table(row, credentials_are_encrypted=False)
|
||||
|
||||
mock_discovery.assert_not_awaited()
|
||||
assert built.authorization_url == "https://idp.example.com/manual-authorize"
|
||||
assert built.token_url == "https://idp.example.com/manual-token"
|
||||
assert built.scopes == ["calendar.read"]
|
||||
|
||||
async def _capture_subject_token(self, call) -> Optional[str]:
|
||||
"""Run a manager method (via ``call(manager)``) and return the subject_token it threaded
|
||||
into ``_create_mcp_client``."""
|
||||
|
|
@ -2127,6 +2381,46 @@ class TestMCPServerManager:
|
|||
assert result.scopes == ["api://some-scope/.default"]
|
||||
assert result.from_origin_fallback is False
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_descovery_metadata_scopes_are_resource_driven(self):
|
||||
"""The effective `scopes` are resource-driven: the RFC 9728 protected-resource advertisement
|
||||
(or WWW-Authenticate challenge) overrides the authorization server's own scopes_supported. This
|
||||
is the MCP Scope Selection Strategy: the client requests what the resource needs, not the AS's
|
||||
full capability list."""
|
||||
manager = MCPServerManager()
|
||||
|
||||
mock_response = MagicMock()
|
||||
mock_response.raise_for_status = MagicMock()
|
||||
mock_client = MagicMock()
|
||||
mock_client.get = AsyncMock(return_value=mock_response)
|
||||
|
||||
authorization_server_metadata = MCPOAuthMetadata(
|
||||
scopes=["as.read", "as.write"],
|
||||
authorization_url="https://idp.example.com/authorize",
|
||||
token_url="https://idp.example.com/token",
|
||||
)
|
||||
|
||||
with (
|
||||
patch(
|
||||
"litellm.proxy._experimental.mcp_server.mcp_server_manager.get_async_httpx_client",
|
||||
return_value=mock_client,
|
||||
),
|
||||
patch.object(
|
||||
manager,
|
||||
"_attempt_well_known_discovery",
|
||||
AsyncMock(return_value=(["https://idp.example.com"], ["resource.only"])),
|
||||
),
|
||||
patch.object(
|
||||
manager,
|
||||
"_fetch_authorization_server_metadata",
|
||||
AsyncMock(return_value=authorization_server_metadata),
|
||||
),
|
||||
):
|
||||
result = await manager._descovery_metadata("https://up.example.com/mcp")
|
||||
|
||||
assert result is not None
|
||||
assert result.scopes == ["resource.only"]
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_fetch_single_authorization_server_metadata_supports_azure_issuer_path(
|
||||
self,
|
||||
|
|
@ -2252,6 +2546,10 @@ class TestMCPServerManager:
|
|||
|
||||
@pytest.mark.asyncio
|
||||
async def test_load_servers_from_config_overrides_discovery_metadata(self):
|
||||
"""Config values win per field. The discovered token_url/registration_url do NOT fill the
|
||||
blanks here: the document advertises a different authorization_endpoint than the manually
|
||||
configured one, so combining its endpoints with the pinned authorize URL would be the
|
||||
config-time mix-up the discovery gate exists to prevent."""
|
||||
manager = MCPServerManager()
|
||||
|
||||
discovered_metadata = MCPOAuthMetadata(
|
||||
|
|
@ -2285,8 +2583,8 @@ class TestMCPServerManager:
|
|||
server = next(iter(manager.config_mcp_servers.values()))
|
||||
assert server.scopes == ["config"] # config overrides discovery
|
||||
assert server.authorization_url == "https://config.example.com/auth"
|
||||
assert server.token_url == "https://discovered.example.com/token"
|
||||
assert server.registration_url == "https://discovered.example.com/register"
|
||||
assert server.token_url is None
|
||||
assert server.registration_url is None
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_load_servers_from_config_filters_blank_scopes(self):
|
||||
|
|
@ -5093,6 +5391,102 @@ class TestMCPServerTimestamps:
|
|||
_carry_forward_resolved_oauth_endpoints(new_server=explicit, previous_server=previous)
|
||||
assert explicit.authorization_url == "https://configured.example.com/auth"
|
||||
|
||||
def test_carry_forward_does_not_revive_token_url_across_authorization_url_change(self):
|
||||
"""Carry-forward is a non-manual endpoint source, so it obeys the same trust rule as
|
||||
discovery: a previous token_url/registration_url belongs to the previous authorization
|
||||
server, so it must not be pinned to a NEW authorization_url the admin re-pointed to. Without
|
||||
this, re-pointing authorize to server B while the same MCP url keeps serving A's token
|
||||
endpoint recreates the RFC 9700 mix-up, durably, and the discovery gate alone cannot catch
|
||||
it because the stale endpoint comes from the registry, not from discovery."""
|
||||
from litellm.proxy._experimental.mcp_server.mcp_server_manager import (
|
||||
_carry_forward_resolved_oauth_endpoints,
|
||||
)
|
||||
|
||||
previous = MCPServer(
|
||||
server_id="s1",
|
||||
name="s1",
|
||||
url="https://up.example.com/mcp",
|
||||
transport=MCPTransport.http,
|
||||
auth_type=MCPAuth.oauth2,
|
||||
authorization_url="https://idp-a.example.com/authorize",
|
||||
token_url="https://idp-a.example.com/token",
|
||||
registration_url="https://idp-a.example.com/register",
|
||||
)
|
||||
repointed = MCPServer(
|
||||
server_id="s1",
|
||||
name="s1",
|
||||
url="https://up.example.com/mcp",
|
||||
transport=MCPTransport.http,
|
||||
auth_type=MCPAuth.oauth2,
|
||||
authorization_url="https://idp-b.example.com/authorize",
|
||||
)
|
||||
|
||||
_carry_forward_resolved_oauth_endpoints(new_server=repointed, previous_server=previous)
|
||||
|
||||
assert repointed.authorization_url == "https://idp-b.example.com/authorize"
|
||||
assert repointed.token_url is None
|
||||
assert repointed.registration_url is None
|
||||
|
||||
def test_carry_forward_restores_endpoints_when_authorization_url_unchanged(self):
|
||||
"""The last-known-good path still works: a rebuild whose discovery blipped (no authorize
|
||||
endpoint) adopts the previous authorize endpoint AND its token endpoint together as a
|
||||
consistent group, and a rebuild that re-pins the same authorize endpoint (formatting aside)
|
||||
keeps carrying the corroborated token endpoint."""
|
||||
from litellm.proxy._experimental.mcp_server.mcp_server_manager import (
|
||||
_carry_forward_resolved_oauth_endpoints,
|
||||
)
|
||||
|
||||
def previous() -> MCPServer:
|
||||
return MCPServer(
|
||||
server_id="s1",
|
||||
name="s1",
|
||||
url="https://up.example.com/mcp",
|
||||
transport=MCPTransport.http,
|
||||
auth_type=MCPAuth.oauth2,
|
||||
authorization_url="https://idp.example.com/authorize",
|
||||
token_url="https://idp.example.com/token",
|
||||
registration_url="https://idp.example.com/register",
|
||||
)
|
||||
|
||||
blipped = MCPServer(
|
||||
server_id="s1",
|
||||
name="s1",
|
||||
url="https://up.example.com/mcp",
|
||||
transport=MCPTransport.http,
|
||||
auth_type=MCPAuth.oauth2,
|
||||
authorization_url=None,
|
||||
)
|
||||
_carry_forward_resolved_oauth_endpoints(new_server=blipped, previous_server=previous())
|
||||
assert blipped.authorization_url == "https://idp.example.com/authorize"
|
||||
assert blipped.token_url == "https://idp.example.com/token"
|
||||
assert blipped.registration_url == "https://idp.example.com/register"
|
||||
|
||||
same_authorize = MCPServer(
|
||||
server_id="s1",
|
||||
name="s1",
|
||||
url="https://up.example.com/mcp",
|
||||
transport=MCPTransport.http,
|
||||
auth_type=MCPAuth.oauth2,
|
||||
authorization_url="https://IDP.example.com:443/authorize/",
|
||||
)
|
||||
_carry_forward_resolved_oauth_endpoints(new_server=same_authorize, previous_server=previous())
|
||||
assert same_authorize.token_url == "https://idp.example.com/token"
|
||||
assert same_authorize.registration_url == "https://idp.example.com/register"
|
||||
|
||||
def test_normalized_authorize_endpoint_treats_default_port_and_slash_as_identity(self):
|
||||
"""The corroboration check must not fail on formatting-only differences an IdP legitimately
|
||||
emits: default port, trailing slash, host case, and query string are not identity, but a
|
||||
non-default port is."""
|
||||
from litellm.proxy._experimental.mcp_server.mcp_server_manager import (
|
||||
_normalized_authorize_endpoint,
|
||||
)
|
||||
|
||||
canonical = _normalized_authorize_endpoint("https://idp.example.com/authorize")
|
||||
assert _normalized_authorize_endpoint("https://idp.example.com:443/authorize") == canonical
|
||||
assert _normalized_authorize_endpoint("https://IDP.example.com/authorize/") == canonical
|
||||
assert _normalized_authorize_endpoint("https://idp.example.com/authorize?prompt=consent") == canonical
|
||||
assert _normalized_authorize_endpoint("https://idp.example.com:8443/authorize") != canonical
|
||||
|
||||
def test_build_mcp_server_table_preserves_timestamps(self):
|
||||
"""_build_mcp_server_table must use the MCPServer's stored timestamps, not datetime.now()."""
|
||||
manager = MCPServerManager()
|
||||
|
|
|
|||
|
|
@ -2387,6 +2387,16 @@ class TestSubCallMetadataSanitization:
|
|||
assert sanitized["user_api_key_auth"] is not None
|
||||
assert _get_budget_reservation_from_metadata(sanitized) is None
|
||||
|
||||
def test_returns_empty_dict_for_missing_metadata(self):
|
||||
from litellm.router_strategy.complexity_router.complexity_router import (
|
||||
_classifier_call_metadata,
|
||||
)
|
||||
|
||||
for absent in (None, {}):
|
||||
result = _classifier_call_metadata(absent)
|
||||
assert result == {}
|
||||
assert isinstance(result, dict)
|
||||
|
||||
def test_sanitized_auth_keeps_access_group_fields_and_leaves_original_untouched(self):
|
||||
from litellm.proxy._types import UserAPIKeyAuth
|
||||
from litellm.router_strategy.complexity_router.complexity_router import (
|
||||
|
|
|
|||
|
|
@ -10,7 +10,7 @@ import { Button } from "@/components/ui/button";
|
|||
import MessageManager from "@/components/molecules/message_manager";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useChatShell } from "@/contexts/ChatShellContext";
|
||||
import { CHAT_ROUTES } from "@/components/chat/ChatShell";
|
||||
import { getChatRoutes } from "@/components/chat/ChatShell";
|
||||
import ChatMessages from "@/components/chat/ChatMessages";
|
||||
import MCPConnectPicker from "@/components/chat/MCPConnectPicker";
|
||||
import { fetchAvailableModels } from "@/components/llm_calls/fetch_models";
|
||||
|
|
@ -87,7 +87,7 @@ export default function ChatConversationPage() {
|
|||
const streamScrollLock = useRef<number | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (staleId) router.replace(CHAT_ROUTES.chats);
|
||||
if (staleId) router.replace(getChatRoutes().chats);
|
||||
}, [staleId, router]);
|
||||
|
||||
// Load models
|
||||
|
|
@ -140,7 +140,7 @@ export default function ChatConversationPage() {
|
|||
if (!convId) {
|
||||
convId = createConversation(model);
|
||||
setResponsesSessionId(null); // new conversation starts a fresh session
|
||||
router.push(`${CHAT_ROUTES.chats}?id=${convId}`);
|
||||
window.history.pushState(null, "", `${window.location.pathname}?id=${convId}`);
|
||||
}
|
||||
|
||||
appendMessage(convId, { role: "user", content: trimmed });
|
||||
|
|
@ -248,7 +248,6 @@ export default function ChatConversationPage() {
|
|||
createConversation,
|
||||
appendMessage,
|
||||
updateLastAssistantMessage,
|
||||
router,
|
||||
isStreaming,
|
||||
responsesSessionId,
|
||||
],
|
||||
|
|
@ -529,7 +528,7 @@ export default function ChatConversationPage() {
|
|||
Chat with 100+ LLMs + MCP tools; authenticate once, use them here.{" "}
|
||||
<Button
|
||||
variant="link"
|
||||
onClick={() => router.push(CHAT_ROUTES.integrations)}
|
||||
onClick={() => router.push(getChatRoutes().integrations)}
|
||||
className="h-auto p-0 text-sm font-medium"
|
||||
>
|
||||
Open Integrations ->
|
||||
|
|
|
|||
70
ui/litellm-dashboard/src/components/BetaBadge.test.tsx
Normal file
70
ui/litellm-dashboard/src/components/BetaBadge.test.tsx
Normal file
|
|
@ -0,0 +1,70 @@
|
|||
import { render, screen } from "@testing-library/react";
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import BetaBadge from "./BetaBadge";
|
||||
|
||||
// Mock the hook directly
|
||||
vi.mock("@/app/(dashboard)/hooks/useDisableShowNewBadge", () => ({
|
||||
useDisableShowNewBadge: vi.fn(),
|
||||
}));
|
||||
|
||||
import { useDisableShowNewBadge } from "@/app/(dashboard)/hooks/useDisableShowNewBadge";
|
||||
|
||||
const mockUseDisableShowNewBadge = vi.mocked(useDisableShowNewBadge);
|
||||
|
||||
describe("BetaBadge", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("should render the badge when disableShowNewBadge is false", () => {
|
||||
mockUseDisableShowNewBadge.mockReturnValue(false);
|
||||
|
||||
render(<BetaBadge>Test Content</BetaBadge>);
|
||||
|
||||
expect(screen.getByText("Beta")).toBeInTheDocument();
|
||||
expect(screen.getByText("Test Content")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should render the badge when disableShowNewBadge is not set", () => {
|
||||
mockUseDisableShowNewBadge.mockReturnValue(false);
|
||||
|
||||
render(<BetaBadge />);
|
||||
|
||||
expect(screen.getByText("Beta")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should render only children when disableShowNewBadge is true", () => {
|
||||
mockUseDisableShowNewBadge.mockReturnValue(true);
|
||||
|
||||
render(<BetaBadge>Test Content</BetaBadge>);
|
||||
|
||||
expect(screen.queryByText("Beta")).not.toBeInTheDocument();
|
||||
expect(screen.getByText("Test Content")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should render nothing when disableShowNewBadge is true and no children", () => {
|
||||
mockUseDisableShowNewBadge.mockReturnValue(true);
|
||||
|
||||
const { container } = render(<BetaBadge />);
|
||||
|
||||
expect(container.firstChild).toBeNull();
|
||||
});
|
||||
|
||||
it("should render badge with dot instead of text when dot prop is true", () => {
|
||||
mockUseDisableShowNewBadge.mockReturnValue(false);
|
||||
|
||||
render(<BetaBadge dot={true}>Test Content</BetaBadge>);
|
||||
|
||||
expect(screen.queryByText("Beta")).not.toBeInTheDocument();
|
||||
expect(screen.getByText("Test Content")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should render badge with 'Beta' text when dot prop is not provided (defaults to false)", () => {
|
||||
mockUseDisableShowNewBadge.mockReturnValue(false);
|
||||
|
||||
render(<BetaBadge>Test Content</BetaBadge>);
|
||||
|
||||
expect(screen.getByText("Beta")).toBeInTheDocument();
|
||||
expect(screen.getByText("Test Content")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
18
ui/litellm-dashboard/src/components/BetaBadge.tsx
Normal file
18
ui/litellm-dashboard/src/components/BetaBadge.tsx
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
import { Badge } from "antd";
|
||||
import { useDisableShowNewBadge } from "@/app/(dashboard)/hooks/useDisableShowNewBadge";
|
||||
|
||||
export default function BetaBadge({ children, dot = false }: { children?: React.ReactNode; dot?: boolean }) {
|
||||
const disableShowNewBadge = useDisableShowNewBadge();
|
||||
|
||||
if (disableShowNewBadge) {
|
||||
return children ? <>{children}</> : null;
|
||||
}
|
||||
|
||||
return children ? (
|
||||
<Badge color="blue" count={dot ? undefined : "Beta"} dot={dot}>
|
||||
{children}
|
||||
</Badge>
|
||||
) : (
|
||||
<Badge color="blue" count={dot ? undefined : "Beta"} dot={dot} />
|
||||
);
|
||||
}
|
||||
|
|
@ -128,6 +128,29 @@ describe("SidebarUsageCard", () => {
|
|||
expect(container.querySelector('[data-slot="meter"]')).toBeNull();
|
||||
});
|
||||
|
||||
it("shows the exact license expiration date as the subtitle instead of time remaining", async () => {
|
||||
mockUseLicenseInfo.mockReturnValue(licenseResult({ ...ACTIVE_LICENSE, expiration_date: "2099-12-31" }));
|
||||
|
||||
renderWithClient(<SidebarUsageCard accessToken="token" collapsed={false} onExpandRail={() => {}} />);
|
||||
|
||||
expect(await screen.findByText("Expires Dec 31, 2099")).toBeInTheDocument();
|
||||
expect(screen.queryByText(/(day|days|month|months) remaining/)).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows the exact date as the subtitle when the license is expired", async () => {
|
||||
mockUseLicenseInfo.mockReturnValue(licenseResult({ ...ACTIVE_LICENSE, expiration_date: "2020-01-01" }));
|
||||
|
||||
renderWithClient(<SidebarUsageCard accessToken="token" collapsed={false} onExpandRail={() => {}} />);
|
||||
|
||||
expect(await screen.findByText("Expired Jan 1, 2020")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("falls back to Active plan when the license has no expiration date", async () => {
|
||||
renderWithClient(<SidebarUsageCard accessToken="token" collapsed={false} onExpandRail={() => {}} />);
|
||||
|
||||
expect(await screen.findByText("Active plan")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows a collapsed rail button that expands the sidebar", async () => {
|
||||
const onExpandRail = vi.fn();
|
||||
const user = userEvent.setup();
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import { useDisableUsageIndicator } from "@/app/(dashboard)/hooks/useDisableUsageIndicator";
|
||||
import { useLicenseInfo } from "@/app/(dashboard)/hooks/license/useLicenseInfo";
|
||||
import { getDaysUntilExpiration } from "@/utils/licenseUtils";
|
||||
import { formatExpirationStatus } from "@/utils/licenseUtils";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/components/ui/collapsible";
|
||||
import { Meter, MeterIndicator, MeterLabel, MeterTrack } from "@/components/ui/meter";
|
||||
|
|
@ -20,16 +20,6 @@ interface MeterData {
|
|||
total: number;
|
||||
}
|
||||
|
||||
const formatExpiration = (daysRemaining: number | null): string => {
|
||||
if (daysRemaining === null) return "No expiration";
|
||||
if (daysRemaining < 0) return "Expired";
|
||||
if (daysRemaining === 0) return "Expires today";
|
||||
if (daysRemaining === 1) return "1 day remaining";
|
||||
if (daysRemaining < 30) return `${daysRemaining} days remaining`;
|
||||
if (daysRemaining < 60) return "1 month remaining";
|
||||
return `${Math.floor(daysRemaining / 30)} months remaining`;
|
||||
};
|
||||
|
||||
const meterTone = (pct: number): "default" | "warning" | "over" => {
|
||||
if (pct > 100) return "over";
|
||||
if (pct >= 80) return "warning";
|
||||
|
|
@ -104,8 +94,7 @@ export default function SidebarUsageCard({ accessToken, collapsed, onExpandRail
|
|||
);
|
||||
}
|
||||
|
||||
const daysUntilExpiration = licenseInfo?.expiration_date ? getDaysUntilExpiration(licenseInfo.expiration_date) : null;
|
||||
const subtitle = licenseInfo?.expiration_date ? formatExpiration(daysUntilExpiration) : "Active plan";
|
||||
const subtitle = licenseInfo?.expiration_date ? formatExpirationStatus(licenseInfo.expiration_date) : "Active plan";
|
||||
const meters = buildMeters(data);
|
||||
|
||||
return (
|
||||
|
|
|
|||
|
|
@ -14,9 +14,19 @@ vi.mock("@/app/(dashboard)/hooks/useDisableUsageIndicator", () => ({
|
|||
useDisableUsageIndicator: vi.fn(() => false),
|
||||
}));
|
||||
|
||||
import { getRemainingUsers } from "./networking";
|
||||
import { getLicenseInfo, getRemainingUsers } from "./networking";
|
||||
import type { LicenseInfo } from "./networking";
|
||||
|
||||
const mockGetRemainingUsers = vi.mocked(getRemainingUsers);
|
||||
const mockGetLicenseInfo = vi.mocked(getLicenseInfo);
|
||||
|
||||
const licenseWithExpiry = (expiration_date: string): LicenseInfo => ({
|
||||
has_license: true,
|
||||
license_type: "enterprise",
|
||||
expiration_date,
|
||||
allowed_features: [],
|
||||
limits: { max_users: null, max_teams: null },
|
||||
});
|
||||
|
||||
const renderWithClient = (ui: React.ReactElement) => {
|
||||
const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
||||
|
|
@ -36,6 +46,7 @@ describe("UsageIndicator", () => {
|
|||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mockGetRemainingUsers.mockResolvedValue(DEFAULT_USAGE_DATA);
|
||||
mockGetLicenseInfo.mockResolvedValue(null);
|
||||
});
|
||||
|
||||
it("should render when given access token and usage data loads", async () => {
|
||||
|
|
@ -126,6 +137,23 @@ describe("UsageIndicator", () => {
|
|||
expect(screen.getByText("Over limit")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should show the exact license expiration date instead of time remaining", async () => {
|
||||
mockGetLicenseInfo.mockResolvedValue(licenseWithExpiry("2099-12-31"));
|
||||
|
||||
renderWithClient(<UsageIndicator accessToken="token" width={220} />);
|
||||
|
||||
expect(await screen.findByText("Expires Dec 31, 2099")).toBeInTheDocument();
|
||||
expect(screen.queryByText(/(day|days|month|months) remaining/)).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should show the exact date when the license is expired", async () => {
|
||||
mockGetLicenseInfo.mockResolvedValue(licenseWithExpiry("2020-01-01"));
|
||||
|
||||
renderWithClient(<UsageIndicator accessToken="token" width={220} />);
|
||||
|
||||
expect(await screen.findByText("Expired Jan 1, 2020")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should render nothing when accessToken is null", () => {
|
||||
renderWithClient(<UsageIndicator accessToken={null} width={220} />);
|
||||
|
||||
|
|
|
|||
|
|
@ -15,7 +15,7 @@ import { useEffect, useState } from "react";
|
|||
import { getRemainingUsers } from "./networking";
|
||||
|
||||
import { cn } from "@/lib/cva.config";
|
||||
import { getDaysUntilExpiration } from "@/utils/licenseUtils";
|
||||
import { formatExpirationStatus, getDaysUntilExpiration } from "@/utils/licenseUtils";
|
||||
import { useLicenseInfo } from "@/app/(dashboard)/hooks/license/useLicenseInfo";
|
||||
|
||||
interface UsageIndicatorProps {
|
||||
|
|
@ -32,18 +32,6 @@ interface UsageData {
|
|||
total_teams_remaining: number | null;
|
||||
}
|
||||
|
||||
// Format expiration for display
|
||||
const formatExpirationDisplay = (daysRemaining: number | null): string => {
|
||||
if (daysRemaining === null) return "No expiration";
|
||||
if (daysRemaining < 0) return "Expired";
|
||||
if (daysRemaining === 0) return "Expires today";
|
||||
if (daysRemaining === 1) return "1 day remaining";
|
||||
if (daysRemaining < 30) return `${daysRemaining} days remaining`;
|
||||
if (daysRemaining < 60) return "1 month remaining";
|
||||
const months = Math.floor(daysRemaining / 30);
|
||||
return `${months} months remaining`;
|
||||
};
|
||||
|
||||
export default function UsageIndicator({ accessToken, width = 220 }: UsageIndicatorProps) {
|
||||
const disableUsageIndicator = useDisableUsageIndicator();
|
||||
const [isExpanded, setIsExpanded] = useState(false);
|
||||
|
|
@ -292,7 +280,7 @@ export default function UsageIndicator({ accessToken, width = 220 }: UsageIndica
|
|||
) : isLicenseExpiringSoon ? (
|
||||
<TrendingUp className="h-3 w-3" />
|
||||
) : null}
|
||||
<span className="truncate">{formatExpirationDisplay(daysUntilExpiration)}</span>
|
||||
<span className="truncate">{formatExpirationStatus(licenseInfo.expiration_date)}</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
|
@ -529,7 +517,7 @@ export default function UsageIndicator({ accessToken, width = 220 }: UsageIndica
|
|||
isLicenseExpiringSoon && "text-yellow-600",
|
||||
)}
|
||||
>
|
||||
{formatExpirationDisplay(daysUntilExpiration)}
|
||||
{formatExpirationStatus(licenseInfo.expiration_date)}
|
||||
</span>
|
||||
</div>
|
||||
{licenseInfo.license_type && (
|
||||
|
|
|
|||
|
|
@ -0,0 +1,43 @@
|
|||
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
|
||||
|
||||
// Regression for the chat sidebar / first-message navigation under SERVER_ROOT_PATH.
|
||||
// getChatRoutes() must read the server root path at call time. The previous
|
||||
// module-level `CHAT_ROUTES` captured it once at import, before the UI-config
|
||||
// bootstrap runs setServerRootPath, so every chat route was permanently
|
||||
// unprefixed and router.push() navigated to a 404 (which, mid-stream, also
|
||||
// aborted the first message). These tests deliberately apply the root path
|
||||
// AFTER importing the module so a frozen-at-import implementation fails.
|
||||
describe("getChatRoutes under server_root_path", () => {
|
||||
beforeEach(() => {
|
||||
vi.resetModules();
|
||||
vi.stubEnv("NODE_ENV", "test");
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllEnvs();
|
||||
});
|
||||
|
||||
it("reflects a server root path applied after the module is loaded", async () => {
|
||||
const { getChatRoutes } = await import("./ChatShell");
|
||||
const { setServerRootPath } = await import("@/lib/serverRootPath");
|
||||
|
||||
setServerRootPath("/gw");
|
||||
|
||||
const routes = getChatRoutes();
|
||||
expect(routes.chats).toBe("/gw/ui/chat");
|
||||
expect(routes.integrations).toBe("/gw/ui/chat/integrations");
|
||||
expect(routes.credentials).toBe("/gw/ui/chat/credentials");
|
||||
expect(routes.apiKeys).toBe("/gw/ui/chat/api-keys");
|
||||
expect(routes.usage).toBe("/gw/ui/chat/usage");
|
||||
});
|
||||
|
||||
it("builds /ui-rooted paths when no server root path is set", async () => {
|
||||
const { getChatRoutes } = await import("./ChatShell");
|
||||
const { setServerRootPath } = await import("@/lib/serverRootPath");
|
||||
|
||||
setServerRootPath("/");
|
||||
|
||||
expect(getChatRoutes().chats).toBe("/ui/chat");
|
||||
expect(getChatRoutes().integrations).toBe("/ui/chat/integrations");
|
||||
});
|
||||
});
|
||||
|
|
@ -9,14 +9,16 @@ import { migratedHref } from "@/utils/migratedPages";
|
|||
import { useChatShell } from "@/contexts/ChatShellContext";
|
||||
import ConversationList from "./ConversationList";
|
||||
|
||||
const CHAT_BASE = migratedHref("chat");
|
||||
export const CHAT_ROUTES = {
|
||||
chats: CHAT_BASE,
|
||||
integrations: `${CHAT_BASE}/integrations`,
|
||||
credentials: `${CHAT_BASE}/credentials`,
|
||||
apiKeys: `${CHAT_BASE}/api-keys`,
|
||||
usage: `${CHAT_BASE}/usage`,
|
||||
};
|
||||
export function getChatRoutes() {
|
||||
const base = migratedHref("chat");
|
||||
return {
|
||||
chats: base,
|
||||
integrations: `${base}/integrations`,
|
||||
credentials: `${base}/credentials`,
|
||||
apiKeys: `${base}/api-keys`,
|
||||
usage: `${base}/usage`,
|
||||
};
|
||||
}
|
||||
|
||||
function stripTrailingSlash(path: string): string {
|
||||
return path.length > 1 ? path.replace(/\/+$/, "") : path;
|
||||
|
|
@ -54,7 +56,8 @@ const ChatShell: React.FC<ChatShellProps> = ({ children }) => {
|
|||
const pathname = stripTrailingSlash(usePathname() ?? "");
|
||||
const { conversations, activeConversationId, deleteConversation, renameConversation } = useChatShell();
|
||||
|
||||
const isChatsRoute = pathname === CHAT_ROUTES.chats;
|
||||
const routes = getChatRoutes();
|
||||
const isChatsRoute = pathname === routes.chats;
|
||||
|
||||
return (
|
||||
<div className="flex h-full w-full flex-col bg-background overflow-hidden">
|
||||
|
|
@ -73,7 +76,7 @@ const ChatShell: React.FC<ChatShellProps> = ({ children }) => {
|
|||
<div className="flex flex-1 min-h-0 overflow-hidden">
|
||||
<div className="shrink-0 bg-sidebar border-sidebar-border border-r flex flex-col overflow-hidden w-[260px]">
|
||||
<div className="px-2 pt-3 pb-1 shrink-0">
|
||||
<Button onClick={() => router.push(CHAT_ROUTES.chats)} className="w-full justify-start gap-2.5">
|
||||
<Button onClick={() => router.push(routes.chats)} className="w-full justify-start gap-2.5">
|
||||
<Plus className="h-4 w-4" />
|
||||
New Chat
|
||||
</Button>
|
||||
|
|
@ -85,32 +88,32 @@ const ChatShell: React.FC<ChatShellProps> = ({ children }) => {
|
|||
<NavItem
|
||||
icon={<MessageSquare className="h-4 w-4" />}
|
||||
label="Chats"
|
||||
onClick={() => router.push(CHAT_ROUTES.chats)}
|
||||
onClick={() => router.push(routes.chats)}
|
||||
active={isChatsRoute}
|
||||
/>
|
||||
<NavItem
|
||||
icon={<LayoutGrid className="h-4 w-4" />}
|
||||
label="Integrations"
|
||||
onClick={() => router.push(CHAT_ROUTES.integrations)}
|
||||
active={pathname === CHAT_ROUTES.integrations}
|
||||
onClick={() => router.push(routes.integrations)}
|
||||
active={pathname === routes.integrations}
|
||||
/>
|
||||
<NavItem
|
||||
icon={<KeyRound className="h-4 w-4" />}
|
||||
label="Credentials"
|
||||
onClick={() => router.push(CHAT_ROUTES.credentials)}
|
||||
active={pathname === CHAT_ROUTES.credentials}
|
||||
onClick={() => router.push(routes.credentials)}
|
||||
active={pathname === routes.credentials}
|
||||
/>
|
||||
<NavItem
|
||||
icon={<Lock className="h-4 w-4" />}
|
||||
label="API Keys"
|
||||
onClick={() => router.push(CHAT_ROUTES.apiKeys)}
|
||||
active={pathname === CHAT_ROUTES.apiKeys}
|
||||
onClick={() => router.push(routes.apiKeys)}
|
||||
active={pathname === routes.apiKeys}
|
||||
/>
|
||||
<NavItem
|
||||
icon={<BarChart3 className="h-4 w-4" />}
|
||||
label="Usage"
|
||||
onClick={() => router.push(CHAT_ROUTES.usage)}
|
||||
active={pathname === CHAT_ROUTES.usage}
|
||||
onClick={() => router.push(routes.usage)}
|
||||
active={pathname === routes.usage}
|
||||
/>
|
||||
</div>
|
||||
|
||||
|
|
@ -120,10 +123,10 @@ const ChatShell: React.FC<ChatShellProps> = ({ children }) => {
|
|||
<ConversationList
|
||||
conversations={conversations}
|
||||
activeConversationId={activeConversationId}
|
||||
onSelect={(id) => router.push(`${CHAT_ROUTES.chats}?id=${id}`)}
|
||||
onSelect={(id) => router.push(`${routes.chats}?id=${id}`)}
|
||||
onDelete={(id) => {
|
||||
deleteConversation(id);
|
||||
if (id === activeConversationId) router.push(CHAT_ROUTES.chats);
|
||||
if (id === activeConversationId) router.push(routes.chats);
|
||||
}}
|
||||
onRename={renameConversation}
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -52,6 +52,7 @@ export function useChatHistory(
|
|||
): {
|
||||
conversations: Conversation[];
|
||||
activeConversation: Conversation | null;
|
||||
currentActiveId: string | null;
|
||||
storageUnavailable: boolean;
|
||||
staleId: boolean;
|
||||
createConversation: (model: string) => string;
|
||||
|
|
@ -208,6 +209,7 @@ export function useChatHistory(
|
|||
return {
|
||||
conversations,
|
||||
activeConversation,
|
||||
currentActiveId,
|
||||
storageUnavailable,
|
||||
staleId,
|
||||
createConversation,
|
||||
|
|
|
|||
|
|
@ -72,6 +72,7 @@ import {
|
|||
rolesAllowedToViewWriteScopedPages,
|
||||
rolesWithWriteAccess,
|
||||
} from "../utils/roles";
|
||||
import BetaBadge from "./BetaBadge";
|
||||
import NewBadge from "./common_components/NewBadge";
|
||||
import type { Organization } from "./networking";
|
||||
import SidebarAccountMenu from "./SidebarAccountMenu/SidebarAccountMenu";
|
||||
|
|
@ -211,7 +212,7 @@ const menuGroups: MenuGroup[] = [
|
|||
page: "projects",
|
||||
label: (
|
||||
<span className="flex items-center gap-2">
|
||||
Projects <NewBadge />
|
||||
Projects <BetaBadge />
|
||||
</span>
|
||||
),
|
||||
icon: <Folder {...ICON} />,
|
||||
|
|
|
|||
|
|
@ -252,7 +252,7 @@ describe("Navbar", () => {
|
|||
expect(screen.queryByRole("button", { name: /^notifications$/i })).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should handle hide new features toggle", async () => {
|
||||
it("should handle hide new feature indicators toggle", async () => {
|
||||
const user = userEvent.setup();
|
||||
|
||||
// Initially disabled
|
||||
|
|
|
|||
|
|
@ -57,12 +57,13 @@ export function ChatShellProvider({
|
|||
children,
|
||||
}: ChatShellProviderProps) {
|
||||
const searchParams = useSearchParams();
|
||||
const activeConversationId = searchParams.get("id");
|
||||
const urlConversationId = searchParams.get("id");
|
||||
const [selectedMCPServers, setSelectedMCPServers] = useState<string[]>([]);
|
||||
|
||||
const {
|
||||
conversations,
|
||||
activeConversation,
|
||||
currentActiveId,
|
||||
storageUnavailable,
|
||||
staleId,
|
||||
createConversation,
|
||||
|
|
@ -71,7 +72,7 @@ export function ChatShellProvider({
|
|||
truncateFromMessage,
|
||||
deleteConversation,
|
||||
renameConversation,
|
||||
} = useChatHistory(activeConversationId, userId);
|
||||
} = useChatHistory(urlConversationId, userId);
|
||||
|
||||
return (
|
||||
<ChatShellContext.Provider
|
||||
|
|
@ -85,7 +86,7 @@ export function ChatShellProvider({
|
|||
setSelectedMCPServers,
|
||||
conversations,
|
||||
activeConversation,
|
||||
activeConversationId,
|
||||
activeConversationId: currentActiveId,
|
||||
storageUnavailable,
|
||||
staleId,
|
||||
createConversation,
|
||||
|
|
|
|||
|
|
@ -1,5 +1,11 @@
|
|||
import { describe, it, expect } from "vitest";
|
||||
import { type LicenseExpiryTier, formatExpiryDate, getDaysUntilExpiration, getLicenseExpiryTier } from "./licenseUtils";
|
||||
import {
|
||||
type LicenseExpiryTier,
|
||||
formatExpirationStatus,
|
||||
formatExpiryDate,
|
||||
getDaysUntilExpiration,
|
||||
getLicenseExpiryTier,
|
||||
} from "./licenseUtils";
|
||||
|
||||
const NOW = new Date("2026-07-08T00:00:00Z");
|
||||
|
||||
|
|
@ -59,3 +65,25 @@ describe("formatExpiryDate", () => {
|
|||
expect(formatExpiryDate("bogus")).toBe("bogus");
|
||||
});
|
||||
});
|
||||
|
||||
describe("formatExpirationStatus", () => {
|
||||
it("shows the exact date for a future expiration", () => {
|
||||
expect(formatExpirationStatus("2026-08-07", NOW)).toBe("Expires Aug 7, 2026");
|
||||
});
|
||||
|
||||
it("still reads as upcoming on the expiration day itself", () => {
|
||||
expect(formatExpirationStatus("2026-07-08", NOW)).toBe("Expires Jul 8, 2026");
|
||||
});
|
||||
|
||||
it("shows the exact date for a past expiration", () => {
|
||||
expect(formatExpirationStatus("2026-07-07", NOW)).toBe("Expired Jul 7, 2026");
|
||||
});
|
||||
|
||||
it("returns No expiration for a null date", () => {
|
||||
expect(formatExpirationStatus(null, NOW)).toBe("No expiration");
|
||||
});
|
||||
|
||||
it("returns No expiration for an unparseable date", () => {
|
||||
expect(formatExpirationStatus("not-a-date", NOW)).toBe("No expiration");
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -49,3 +49,11 @@ export const formatExpiryDate = (expirationDate: string): string => {
|
|||
}
|
||||
return date.toLocaleDateString("en-US", EXPIRY_DATE_FORMAT);
|
||||
};
|
||||
|
||||
export const formatExpirationStatus = (expirationDate: string | null, now: Date = new Date()): string => {
|
||||
const days = getDaysUntilExpiration(expirationDate, now);
|
||||
if (expirationDate === null || days === null) {
|
||||
return "No expiration";
|
||||
}
|
||||
return days < 0 ? `Expired ${formatExpiryDate(expirationDate)}` : `Expires ${formatExpiryDate(expirationDate)}`;
|
||||
};
|
||||
|
|
|
|||
129
uv.lock
generated
129
uv.lock
generated
|
|
@ -10,7 +10,7 @@ resolution-markers = [
|
|||
]
|
||||
|
||||
[options]
|
||||
exclude-newer = "0001-01-01T00:00:00Z" # This has no effect and is included for backwards compatibility when using relative exclude-newer values.
|
||||
exclude-newer = "2026-07-13T00:19:39.570486Z"
|
||||
exclude-newer-span = "P3D"
|
||||
|
||||
[manifest]
|
||||
|
|
@ -222,9 +222,9 @@ name = "aiologic"
|
|||
version = "0.17.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "sniffio", marker = "python_full_version < '3.14'" },
|
||||
{ name = "sniffio", marker = "python_full_version < '3.13'" },
|
||||
{ name = "typing-extensions", marker = "python_full_version < '3.13'" },
|
||||
{ name = "wrapt", marker = "python_full_version < '3.14'" },
|
||||
{ name = "wrapt", marker = "python_full_version < '3.13'" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/53/a7/809482759f40079f4c4328c7318bf569ae25d457f5017aad30a1b9aafedc/aiologic-0.17.0.tar.gz", hash = "sha256:65aa058e858c94cd208badb188e7f00b54dcabb3ba85b34f794db98074d108b9", size = 251625, upload-time = "2026-06-14T12:24:35.367Z" }
|
||||
wheels = [
|
||||
|
|
@ -1047,7 +1047,7 @@ name = "coloredlogs"
|
|||
version = "15.0.1"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "humanfriendly" },
|
||||
{ name = "humanfriendly", marker = "python_full_version < '3.14'" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/cc/c7/eed8f27100517e8c0e6b923d5f0845d0cb99763da6fdee00478f91db7325/coloredlogs-15.0.1.tar.gz", hash = "sha256:7c991aa71a4577af2f82600d8f8f3a89f936baeaf9b50a9c197da014e5bf16b0", size = 278520, upload-time = "2021-06-11T10:22:45.202Z" }
|
||||
wheels = [
|
||||
|
|
@ -1420,7 +1420,7 @@ name = "culsans"
|
|||
version = "0.11.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "aiologic", marker = "python_full_version < '3.14'" },
|
||||
{ name = "aiologic", marker = "python_full_version < '3.13'" },
|
||||
{ name = "typing-extensions", marker = "python_full_version < '3.13'" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/d9/e3/49afa1bc180e0d28008ec6bcdf82a4072d1c7a41032b5b759b60814ca4b0/culsans-0.11.0.tar.gz", hash = "sha256:0b43d0d05dce6106293d114c86e3fb4bfc63088cfe8ff08ed3fe36891447fe33", size = 107546, upload-time = "2025-12-31T23:15:38.196Z" }
|
||||
|
|
@ -1464,80 +1464,15 @@ wheels = [
|
|||
{ url = "https://files.pythonhosted.org/packages/c3/be/d0d44e092656fe7a06b55e6103cbce807cdbdee17884a5367c68c9860853/dataclasses_json-0.6.7-py3-none-any.whl", hash = "sha256:0dbf33f26c8d5305befd61b39d2b3414e8a407bedc2834dea9b8d642666fb40a", size = 28686, upload-time = "2024-06-09T16:20:16.715Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ddtrace"
|
||||
version = "2.19.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
resolution-markers = [
|
||||
"python_full_version == '3.13.*'",
|
||||
"python_full_version == '3.12.*'",
|
||||
"python_full_version == '3.11.*'",
|
||||
"python_full_version < '3.11'",
|
||||
]
|
||||
dependencies = [
|
||||
{ name = "bytecode", marker = "python_full_version < '3.14'" },
|
||||
{ name = "envier", marker = "python_full_version < '3.14'" },
|
||||
{ name = "legacy-cgi", marker = "python_full_version == '3.13.*'" },
|
||||
{ name = "opentelemetry-api", marker = "python_full_version < '3.14'" },
|
||||
{ name = "protobuf", marker = "python_full_version < '3.14'" },
|
||||
{ name = "typing-extensions", marker = "python_full_version < '3.14'" },
|
||||
{ name = "wrapt", marker = "python_full_version < '3.14'" },
|
||||
{ name = "xmltodict", marker = "python_full_version < '3.14'" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/c3/06/417a8a9a8c89dc2fdb94c3acdb3f6f9da835e109c2a217fb5863d0d97df9/ddtrace-2.19.0.tar.gz", hash = "sha256:90d217b1906074881afd3e656a3cd1a630dd798bd25077254588c382a4075345", size = 8708460, upload-time = "2025-01-16T17:19:46.303Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/0c/be/b3fc069ff2a20cc1d053b030268ba6999926232ce2195b4958486a9035ea/ddtrace-2.19.0-cp310-cp310-macosx_12_0_universal2.whl", hash = "sha256:50d79ff042868b4d1d80b424285d755d9e0d466119399c2166a3894b178b85fd", size = 4412309, upload-time = "2025-01-16T17:16:10.884Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/14/69/2d42669829c09eefbf4cbabb94dfe7615ee4610019ded28c9634411a574c/ddtrace-2.19.0-cp310-cp310-macosx_12_0_x86_64.whl", hash = "sha256:e17b3b8e1cadf23ed8e4466679a0cb1262aa00190bddcd0fc0f5f6f9a9c25480", size = 3051126, upload-time = "2025-01-16T17:16:15.377Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/57/e0/82d3b5d474ea66e777c38e584053ee7f6ac923218642fdf4967857f48daa/ddtrace-2.19.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7804977b388fed1b1cbb0ef138100be923bf7afbe7d25357fcee07315a66cc8b", size = 6087687, upload-time = "2025-01-16T17:16:17.314Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/54/ba/051ea8720695a8c0ecf7a5d9dcbc7000da18b2cd4efe27af69c09999832d/ddtrace-2.19.0-cp310-cp310-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:c96ae2f074e422202f2b98a021b9fb864fc08bd5864eff27b0ec9da5919c0b1e", size = 2852443, upload-time = "2025-01-16T17:16:20.242Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d8/94/38f7706bbc1b3c010aab457a94edc07ac7145ebfd8ab01797634b60746cb/ddtrace-2.19.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a14662366c5c1d8898c057ef6820de85d71b3ada6fd89638c5ec4ba9d45c21b7", size = 6420509, upload-time = "2025-01-16T17:16:22.521Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/37/f8/b900ffbdf85a06220ca04905caa066dfc1f60643c3d501e92fee08d32951/ddtrace-2.19.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:1638fff37abf61d16f3dbef009c45d4c33b962324b10f642fb7966d0055c28e9", size = 7073719, upload-time = "2025-01-16T17:16:24.518Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e9/a4/12ffed1870c6ecc638283163d11cb675c2840a46dbd741acb2568bf94a6a/ddtrace-2.19.0-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:3f72627f1887d628b025d227a642e5ae30884eedd6f6ef1afee02461bc19c95f", size = 3918050, upload-time = "2025-01-16T17:16:27.279Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/29/35/d4c6a99df2a7ea6219c9b6390ad66ae88f63c86bc7ef84c2a3784f5b5798/ddtrace-2.19.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:d5b8ac83af10e567564d1f016d72549a8b83e06f4c6a3440f7f610ede0b51954", size = 7463892, upload-time = "2025-01-16T17:16:29.299Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/49/7e/881b58c69d7e2316ccc99e8c3b1d4b4d382d5c704a9a0aebc571353b1413/ddtrace-2.19.0-cp310-cp310-win32.whl", hash = "sha256:17971717ad481c2273336957a8c2f328f2e7776f2065821c00332f33cdaa2053", size = 3120778, upload-time = "2025-01-16T17:16:31.278Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/27/39/d5d92f7d0f6d3f98c708c514498562965bd8bfbea8234d1cf3a2ab9f245e/ddtrace-2.19.0-cp310-cp310-win_amd64.whl", hash = "sha256:41629eaa0e16367a45e5fe64b0bd969dd31eb2067e0224e48f149ea976ed5848", size = 3348128, upload-time = "2025-01-16T17:16:33.199Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a6/ec/ac70516f825aba5a5bea78cea568fef6a6c34b80c621bea70d3f9128d3f2/ddtrace-2.19.0-cp311-cp311-macosx_12_0_universal2.whl", hash = "sha256:e58123e8bce549aa159cc3748248987dd2ac63ba4c69c7f0b0d49c2d2c05d20a", size = 4414054, upload-time = "2025-01-16T17:16:36.181Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e7/73/4f0cb04aef8450f23fbe6fc0ba66868bc9e415830fecbe88b65c658866e0/ddtrace-2.19.0-cp311-cp311-macosx_12_0_x86_64.whl", hash = "sha256:1cc7b2b7e9396c17b0356f550b278d1adc5112a0da57e9052169a98b75cbdb66", size = 3052135, upload-time = "2025-01-16T17:16:38.102Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f7/20/0e8d2ef1b1d2c7b4f55b4d2e978bb143fafac36cdc456e8e521b9559c484/ddtrace-2.19.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:cb3a4941c7604f0ee56713c207c1baf8984cb25e0acd9872512d2cd1cd9ef40e", size = 6093484, upload-time = "2025-01-16T17:16:41.192Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/28/f8/af03509c93d91fc35b71c89b02e86635ef2c0d5c56379048c93b4b1d338b/ddtrace-2.19.0-cp311-cp311-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f906e6b05a66c85c7049076c69186b6208a9868086cb114e5cd784e5705d11ed", size = 2858353, upload-time = "2025-01-16T17:16:43.276Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/dc/de/9062ccdd6b0bc00b15dc58bd7bb7ad1e27ab0c78cb4c9ad7218f7dd58106/ddtrace-2.19.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:544a41bba75c547c52595cec535d55ed3a8b109068121682ebe04e48a3af73d9", size = 6426319, upload-time = "2025-01-16T17:16:46.095Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c6/bc/2c8b9afa39c5b8370cb8587a8325bcf01ff6c5d87b07690ae764c3e02a9f/ddtrace-2.19.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:d4c9c4dc015e285368ac29ca263d41ff9480b1df42c5599860c139007a11dd54", size = 7076423, upload-time = "2025-01-16T17:16:49.439Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/18/9c/caa119adf66d4a6b0e7f7d0de8ed6ecfb18ff2249eb55aeed03f412233ce/ddtrace-2.19.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:7965330c03a4793d8bc71c702d49832b9900cf0e3b5f36e9d4c9037285f5fc73", size = 3919949, upload-time = "2025-01-16T17:16:51.98Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/66/53/0d6b96db5c9ee6fdaf010adc968c7dadadbc5031d05e382dbb3088a20ea7/ddtrace-2.19.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:7b203be50ca19182120063a34ccffc34e3016555c8ceb5b1439ae61d7ef88ad0", size = 7470201, upload-time = "2025-01-16T17:16:54.498Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7b/d4/81b8df76e10dbcd85ad1f0bb17075d6334c1abcb4136ad9a03835880fabb/ddtrace-2.19.0-cp311-cp311-win32.whl", hash = "sha256:bed9aa688e7f0185f96407fe9bd20192e767aa812fdaac5552bf3edc4fa5182c", size = 3120927, upload-time = "2025-01-16T17:16:57.998Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/60/73/3ea8f4ddcf3b451ca2523767262fd8d9df76aa1e0403932c5cf49ff73eab/ddtrace-2.19.0-cp311-cp311-win_amd64.whl", hash = "sha256:a40e64a96dbdb5b2124b54051c2de371b895175b62a97273b7f527be9721d8c2", size = 3352777, upload-time = "2025-01-16T17:16:59.998Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/62/64/8c696adb83f2a1a5310d8f64094d8d76417928c136f1b2fc55bb912977ad/ddtrace-2.19.0-cp312-cp312-macosx_12_0_universal2.whl", hash = "sha256:8f5e6e0086717cc7c8fd1ad3da2ee7d5cb30ba3eb0d75ee79b070b310443d884", size = 4852896, upload-time = "2025-01-16T17:17:02.584Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ef/b9/2cd4347db133128429f60044e40600c9016a98e147b110d7020e8767ee60/ddtrace-2.19.0-cp312-cp312-macosx_12_0_x86_64.whl", hash = "sha256:aa38304a6b5c937154acd33dafdc8d1cbdc4c4879e135578515dd9be44241b2c", size = 3280741, upload-time = "2025-01-16T17:17:04.77Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/85/a2/a94bd0e39657b45008cce9c33931f824f27a3db2da655b0b599c44d51617/ddtrace-2.19.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e77b42bd5a269f2bc1ad0ba8141987634c288ee96366ea9505aec8871ee5662f", size = 6062585, upload-time = "2025-01-16T17:17:07.095Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/66/07/f655ede9fbf1c7de2a0a271687d0a31c39e4afc46102b1e73eac342298d8/ddtrace-2.19.0-cp312-cp312-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:56816cdda82b18e8e99ee60d0f70a5cdbd57fb54bdc9038ba01d779139db1fcc", size = 2827198, upload-time = "2025-01-16T17:17:11.978Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0f/9d/a193623a7d9a5226cd63ddbdc42250ef3e6d4b37bc77725ff06b2a9838c4/ddtrace-2.19.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9fd3237342fa7753c47161904bbb3bb691625a99a4b396069fd1db927d20a74c", size = 6398024, upload-time = "2025-01-16T17:17:14.303Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/56/76/43c132d259d1fd710a5ece3abac4e6d7789626ce1ae66536ed0e22fc5361/ddtrace-2.19.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:713beebab7398310f0753e33234cb70b91b3eb387525a7cae5897d19471917f4", size = 7041348, upload-time = "2025-01-16T17:17:16.996Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0a/9e/f59030213600c58f87b4d5d814ded8b9453cbbfdc7c3a02a313f07c62db1/ddtrace-2.19.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:adaf1ef268c5bb3599f3a1e34b1089d77b6e323cd1ec31da482f794f64213aa6", size = 3886975, upload-time = "2025-01-16T17:17:20.77Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/84/c6/626560e37f0024572456d7cc2cafb0ab61da22deb2f9b218231d43053325/ddtrace-2.19.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:0c38952a4f4d1ed61d53bdc55acb9d931c355e1e5a90001b95576e99dcacfc11", size = 7434753, upload-time = "2025-01-16T17:17:24.304Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1f/2a/3c181fc7f2021ec05e95586ca8fa8236f1429adfabb6152bb950b79247a4/ddtrace-2.19.0-cp312-cp312-win32.whl", hash = "sha256:045773c382aada18feeb5584fdba9aa47ff660ac93a94b43b24434760c77802a", size = 3108737, upload-time = "2025-01-16T17:17:27.696Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/cd/81/b000c6919d9cc204fead0069b3523d6a65d0da21a45a686a872b4201013e/ddtrace-2.19.0-cp312-cp312-win_amd64.whl", hash = "sha256:e96980c8e81831c7cb367b1ab066ba4cfaf389be1099c0f15985484a8de6d80d", size = 3343024, upload-time = "2025-01-16T17:17:29.98Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d9/55/32f7142cc96410a534868eb553ef9d238cf44d2cb10c2107cf880d9d42b9/ddtrace-2.19.0-cp313-cp313-macosx_12_0_universal2.whl", hash = "sha256:c3cccef7e15a561ad5e5699ca2f6045d7d1e655c487fd2d617b9541152c3f217", size = 4832649, upload-time = "2025-01-16T17:17:32.785Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1a/60/5d1e99cfa6bc29d13eae55fbe6b395138ce17b96d6e95c9c7b57c071d410/ddtrace-2.19.0-cp313-cp313-macosx_12_0_x86_64.whl", hash = "sha256:a13acabcf0fad276e55e9d35ade74aae91b9629367e06692788ee5ed484491d6", size = 3269614, upload-time = "2025-01-16T17:17:35.273Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5d/75/b3b00c1325d64ab1445a7965554b7a311842ae26f6995a0f64348c597848/ddtrace-2.19.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:724f3954e16bf66f0f45479ba3fffb4fa39fe4e43667c3085d9090b17ea9242d", size = 6016732, upload-time = "2025-01-16T17:17:37.783Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/84/a7/ec1fa6f8ad7254f9baed33c37cb88abf0f324e2740792f8e5f24bd1fbfea/ddtrace-2.19.0-cp313-cp313-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:47bb8980fb8d711d96f66d8727196476bbdafe2cccc501c93b5352c5797a4422", size = 2815983, upload-time = "2025-01-16T17:17:40.305Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/4a/1a/2b8102e738bc4ed335dd3a5bfeba21b554b73abc9ea8d1c47cb7f3ccfbe0/ddtrace-2.19.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:661341280a69d8ceb91e48f67cfa33adb3af901b09d329b8375b1a3ba04a68b7", size = 6351654, upload-time = "2025-01-16T17:17:44.094Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0e/30/56095f289ae7689cb789f2c85e0e227b02bd8de548c25ab0c952cc823051/ddtrace-2.19.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:facae23052586b171c47faecb622a0c8a15beeab0fb3af4d53367a749b1cbded", size = 6996885, upload-time = "2025-01-16T17:17:47.304Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2b/c0/c315500dbde69a4193665c964dd56e9be523b7e05979718140ad2c9a6821/ddtrace-2.19.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:4cb13fdb6587ff1c460b7e673d83979280efa0a5a5a4104fc92ebcaf0c6ca36e", size = 3881860, upload-time = "2025-01-16T17:17:52.081Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/26/8c/e1a7043e562b5b29fb5d0930630a18078fecb1c30ca6776221ce0dab6f95/ddtrace-2.19.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:d36a16e8746cb38a143faa6e1cd10927bf4a482c29f4010afde4bd0f4bb89db4", size = 7390107, upload-time = "2025-01-16T17:17:54.826Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ddtrace"
|
||||
version = "4.11.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
resolution-markers = [
|
||||
"python_full_version >= '3.14'",
|
||||
]
|
||||
dependencies = [
|
||||
{ name = "bytecode", marker = "python_full_version >= '3.14'" },
|
||||
{ name = "envier", marker = "python_full_version >= '3.14'" },
|
||||
{ name = "opentelemetry-api", marker = "python_full_version >= '3.14'" },
|
||||
{ name = "wrapt", marker = "python_full_version >= '3.14'" },
|
||||
{ name = "bytecode" },
|
||||
{ name = "envier" },
|
||||
{ name = "opentelemetry-api" },
|
||||
{ name = "wrapt" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/81/51/a628f0177274bab5b67c93a1558fd222babb15286a427e4e8c1d65a265b2/ddtrace-4.11.0.tar.gz", hash = "sha256:260c5b46e80565f4fd08cec2650f707627fb57cd6f8951a2d8c9e6a02b490074", size = 2422158, upload-time = "2026-07-10T08:57:53.396Z" }
|
||||
wheels = [
|
||||
|
|
@ -3031,7 +2966,7 @@ name = "humanfriendly"
|
|||
version = "10.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "pyreadline3", marker = "sys_platform == 'win32'" },
|
||||
{ name = "pyreadline3", marker = "python_full_version < '3.14' and sys_platform == 'win32'" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/cc/3f/2c29224acb2e2df4d2046e4c73ee2662023c58ff5b113c4c1adac0886c43/humanfriendly-10.0.tar.gz", hash = "sha256:6b0b831ce8f15f7300721aa49829fc4e83921a9a301cc7f606be6686a2288ddc", size = 360702, upload-time = "2021-09-17T21:40:43.31Z" }
|
||||
wheels = [
|
||||
|
|
@ -3795,15 +3730,6 @@ wheels = [
|
|||
{ url = "https://files.pythonhosted.org/packages/41/a0/b91504515c1f9a299fc157967ffbd2f0321bce0516a3d5b89f6f4cad0355/lazy_object_proxy-1.12.0-pp39.pp310.pp311.graalpy311-none-any.whl", hash = "sha256:c3b2e0af1f7f77c4263759c4824316ce458fabe0fceadcd24ef8ca08b2d1e402", size = 15072, upload-time = "2025-08-22T13:50:05.498Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "legacy-cgi"
|
||||
version = "2.6.4"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/f4/9c/91c7d2c5ebbdf0a1a510bfa0ddeaa2fbb5b78677df5ac0a0aa51cf7125b0/legacy_cgi-2.6.4.tar.gz", hash = "sha256:abb9dfc7835772f7c9317977c63253fd22a7484b5c9bbcdca60a29dcce97c577", size = 24603, upload-time = "2025-10-27T05:20:05.395Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/8c/7e/e7394eeb49a41cc514b3eb49020223666cbf40d86f5721c2f07871e6d84a/legacy_cgi-2.6.4-py3-none-any.whl", hash = "sha256:7e235ce58bf1e25d1fc9b2d299015e4e2cd37305eccafec1e6bac3fc04b878cd", size = 20035, upload-time = "2025-10-27T05:20:04.289Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "litellm"
|
||||
version = "1.94.0"
|
||||
|
|
@ -3887,8 +3813,7 @@ proxy-runtime = [
|
|||
{ name = "anthropic", extra = ["vertex"] },
|
||||
{ name = "azure-ai-contentsafety" },
|
||||
{ name = "azure-storage-file-datalake" },
|
||||
{ name = "ddtrace", version = "2.19.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.14'" },
|
||||
{ name = "ddtrace", version = "4.11.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.14'" },
|
||||
{ name = "ddtrace" },
|
||||
{ name = "detect-secrets" },
|
||||
{ name = "google-cloud-aiplatform" },
|
||||
{ name = "google-genai" },
|
||||
|
|
@ -4023,8 +3948,7 @@ requires-dist = [
|
|||
{ name = "boto3", marker = "extra == 'proxy'", specifier = ">=1.43.1,<2.0" },
|
||||
{ name = "click", specifier = ">=8.0.0,<9.0" },
|
||||
{ name = "cryptography", marker = "extra == 'proxy'", specifier = ">=48.0.1,<49.0" },
|
||||
{ name = "ddtrace", marker = "python_full_version >= '3.14' and extra == 'proxy-runtime'", specifier = ">=4.0.0,<5.0" },
|
||||
{ name = "ddtrace", marker = "python_full_version < '3.14' and extra == 'proxy-runtime'", specifier = ">=2.19.0,<3.0" },
|
||||
{ name = "ddtrace", marker = "extra == 'proxy-runtime'", specifier = ">=4.8.2,<5.0" },
|
||||
{ name = "detect-secrets", marker = "extra == 'proxy-runtime'", specifier = ">=1.5.0,<2.0" },
|
||||
{ name = "diskcache", marker = "extra == 'caching'", specifier = ">=5.6.3,<6.0" },
|
||||
{ name = "expression", marker = "extra == 'proxy'", specifier = ">=5.6.0,<6.0" },
|
||||
|
|
@ -4540,7 +4464,7 @@ version = "0.4.1"
|
|||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "numpy", version = "1.26.4", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.12'" },
|
||||
{ name = "numpy", version = "2.4.4", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" },
|
||||
{ name = "numpy", version = "2.4.4", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12' and python_full_version < '3.14'" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/fd/15/76f86faa0902836cc133939732f7611ace68cf54148487a99c539c272dc8/ml_dtypes-0.4.1.tar.gz", hash = "sha256:fad5f2de464fd09127e49b7fd1252b9006fb43d2edc1ff112d390c324af5ca7a", size = 692594, upload-time = "2024-09-13T19:07:11.624Z" }
|
||||
wheels = [
|
||||
|
|
@ -7201,16 +7125,16 @@ name = "redisvl"
|
|||
version = "0.4.1"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "coloredlogs" },
|
||||
{ name = "ml-dtypes" },
|
||||
{ name = "coloredlogs", marker = "python_full_version < '3.14'" },
|
||||
{ name = "ml-dtypes", marker = "python_full_version < '3.14'" },
|
||||
{ name = "numpy", version = "1.26.4", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.12'" },
|
||||
{ name = "numpy", version = "2.4.4", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" },
|
||||
{ name = "pydantic" },
|
||||
{ name = "python-ulid" },
|
||||
{ name = "pyyaml" },
|
||||
{ name = "redis" },
|
||||
{ name = "tabulate" },
|
||||
{ name = "tenacity" },
|
||||
{ name = "numpy", version = "2.4.4", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12' and python_full_version < '3.14'" },
|
||||
{ name = "pydantic", marker = "python_full_version < '3.14'" },
|
||||
{ name = "python-ulid", marker = "python_full_version < '3.14'" },
|
||||
{ name = "pyyaml", marker = "python_full_version < '3.14'" },
|
||||
{ name = "redis", marker = "python_full_version < '3.14'" },
|
||||
{ name = "tabulate", marker = "python_full_version < '3.14'" },
|
||||
{ name = "tenacity", marker = "python_full_version < '3.14'" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/21/33/ab14865a0b2a31b1d003c29e7e8ea3a7a2f2c8ecb24e58e58d606e1f031b/redisvl-0.4.1.tar.gz", hash = "sha256:fd6a36426ba94792c0efca20915c31232d4ee3cc58eb23794a62c142696401e6", size = 77688, upload-time = "2025-02-21T22:51:41.389Z" }
|
||||
wheels = [
|
||||
|
|
@ -9223,15 +9147,6 @@ wheels = [
|
|||
{ url = "https://files.pythonhosted.org/packages/a4/f5/10b68b7b1544245097b2a1b8238f66f2fc6dcaeb24ba5d917f52bd2eed4f/wsproto-1.3.2-py3-none-any.whl", hash = "sha256:61eea322cdf56e8cc904bd3ad7573359a242ba65688716b0710a5eb12beab584", size = 24405, upload-time = "2025-11-20T18:18:00.454Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "xmltodict"
|
||||
version = "1.0.4"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/19/70/80f3b7c10d2630aa66414bf23d210386700aa390547278c789afa994fd7e/xmltodict-1.0.4.tar.gz", hash = "sha256:6d94c9f834dd9e44514162799d344d815a3a4faec913717a9ecbfa5be1bb8e61", size = 26124, upload-time = "2026-02-22T02:21:22.074Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/38/34/98a2f52245f4d47be93b580dae5f9861ef58977d73a79eb47c58f1ad1f3a/xmltodict-1.0.4-py3-none-any.whl", hash = "sha256:a4a00d300b0e1c59fc2bfccb53d7b2e88c32f200df138a0dd2229f842497026a", size = 13580, upload-time = "2026-02-22T02:21:21.039Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "xxhash"
|
||||
version = "3.7.0"
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue