chore: merge litellm_internal_staging

This commit is contained in:
Devin AI 2026-07-16 01:01:04 +00:00
commit aaeb26313b
30 changed files with 994 additions and 217 deletions

View file

@ -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" \

View file

@ -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

View file

@ -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

View file

@ -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]

View file

@ -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

View file

@ -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",

View file

@ -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 "

View file

@ -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"}

View file

@ -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

View 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())

View 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()

View 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"
)

View file

@ -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()

View file

@ -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 (

View file

@ -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 -&gt;

View 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();
});
});

View 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} />
);
}

View file

@ -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();

View file

@ -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 (

View file

@ -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} />);

View file

@ -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 && (

View file

@ -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");
});
});

View file

@ -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}
/>

View file

@ -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,

View file

@ -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} />,

View file

@ -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

View file

@ -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,

View file

@ -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");
});
});

View file

@ -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
View file

@ -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"