diff --git a/.github/assets/roi-calculator/00-original-setup.png b/.github/assets/roi-calculator/00-original-setup.png
new file mode 100644
index 00000000000..95bdeb56907
Binary files /dev/null and b/.github/assets/roi-calculator/00-original-setup.png differ
diff --git a/.github/assets/roi-calculator/01-connect-github.png b/.github/assets/roi-calculator/01-connect-github.png
new file mode 100644
index 00000000000..4214298785a
Binary files /dev/null and b/.github/assets/roi-calculator/01-connect-github.png differ
diff --git a/.github/assets/roi-calculator/02-repositories.png b/.github/assets/roi-calculator/02-repositories.png
new file mode 100644
index 00000000000..81e69c20c2b
Binary files /dev/null and b/.github/assets/roi-calculator/02-repositories.png differ
diff --git a/.github/assets/roi-calculator/03-estimator-schedule.png b/.github/assets/roi-calculator/03-estimator-schedule.png
new file mode 100644
index 00000000000..2934bc969d8
Binary files /dev/null and b/.github/assets/roi-calculator/03-estimator-schedule.png differ
diff --git a/.github/assets/roi-calculator/04-backfill-progress.png b/.github/assets/roi-calculator/04-backfill-progress.png
new file mode 100644
index 00000000000..19026b8042f
Binary files /dev/null and b/.github/assets/roi-calculator/04-backfill-progress.png differ
diff --git a/.github/assets/roi-calculator/06-overview.png b/.github/assets/roi-calculator/06-overview.png
new file mode 100644
index 00000000000..abf2f7a0aaa
Binary files /dev/null and b/.github/assets/roi-calculator/06-overview.png differ
diff --git a/.github/assets/roi-calculator/07-people-unmatched.png b/.github/assets/roi-calculator/07-people-unmatched.png
new file mode 100644
index 00000000000..a605d980f20
Binary files /dev/null and b/.github/assets/roi-calculator/07-people-unmatched.png differ
diff --git a/.github/assets/roi-calculator/08-match-email.png b/.github/assets/roi-calculator/08-match-email.png
new file mode 100644
index 00000000000..9f578fd783c
Binary files /dev/null and b/.github/assets/roi-calculator/08-match-email.png differ
diff --git a/.github/assets/roi-calculator/09-people-matched.png b/.github/assets/roi-calculator/09-people-matched.png
new file mode 100644
index 00000000000..6d72179ae67
Binary files /dev/null and b/.github/assets/roi-calculator/09-people-matched.png differ
diff --git a/.github/assets/roi-calculator/10-pr-reasoning.png b/.github/assets/roi-calculator/10-pr-reasoning.png
new file mode 100644
index 00000000000..423c6bdc3e3
Binary files /dev/null and b/.github/assets/roi-calculator/10-pr-reasoning.png differ
diff --git a/.github/assets/roi-calculator/11-settings.png b/.github/assets/roi-calculator/11-settings.png
new file mode 100644
index 00000000000..1ef5c446408
Binary files /dev/null and b/.github/assets/roi-calculator/11-settings.png differ
diff --git a/.github/assets/roi-calculator/12-restart-setup.png b/.github/assets/roi-calculator/12-restart-setup.png
new file mode 100644
index 00000000000..7a2f410a5e2
Binary files /dev/null and b/.github/assets/roi-calculator/12-restart-setup.png differ
diff --git a/.github/assets/roi-calculator/13-advanced-settings.png b/.github/assets/roi-calculator/13-advanced-settings.png
new file mode 100644
index 00000000000..61549454c88
Binary files /dev/null and b/.github/assets/roi-calculator/13-advanced-settings.png differ
diff --git a/.github/assets/roi-calculator/14-overview-pulls.png b/.github/assets/roi-calculator/14-overview-pulls.png
new file mode 100644
index 00000000000..0f07752c4c3
Binary files /dev/null and b/.github/assets/roi-calculator/14-overview-pulls.png differ
diff --git a/.github/assets/roi-calculator/15-sample-preview.png b/.github/assets/roi-calculator/15-sample-preview.png
new file mode 100644
index 00000000000..6128d0a5dff
Binary files /dev/null and b/.github/assets/roi-calculator/15-sample-preview.png differ
diff --git a/.github/assets/roi-calculator/16-calculator-sidebar.png b/.github/assets/roi-calculator/16-calculator-sidebar.png
new file mode 100644
index 00000000000..8ed3042f36c
Binary files /dev/null and b/.github/assets/roi-calculator/16-calculator-sidebar.png differ
diff --git a/litellm/proxy/management_endpoints/roi_calculator_endpoints.py b/litellm/proxy/management_endpoints/roi_calculator_endpoints.py
index 8134aa811f9..94febf7d940 100644
--- a/litellm/proxy/management_endpoints/roi_calculator_endpoints.py
+++ b/litellm/proxy/management_endpoints/roi_calculator_endpoints.py
@@ -256,7 +256,9 @@ def _gateway_http_client() -> AsyncHTTPHandler:
return get_async_httpx_client(
llm_provider="roi_calculator",
- params={"transport": _gateway_transport(app), "timeout": 180, "follow_redirects": False},
+ params=TypeAdapter(dict[str, object]).validate_python(
+ MappingProxyType({"transport": _gateway_transport(app), "timeout": 180, "follow_redirects": False})
+ ),
)
@@ -425,7 +427,7 @@ async def get_roi_calculator_sync_status(
settings: Final = await _load_settings(repository)
report: Final = await _load_report(repository)
next_update: Final = _next_update(settings, status, report)
- return status.model_copy(update={"next_update": next_update.isoformat() if next_update else None})
+ return status.model_copy(update=MappingProxyType({"next_update": next_update.isoformat() if next_update else None}))
@router.post(
@@ -512,10 +514,10 @@ async def update_roi_calculator_identity_map(
new_email: Final = normalize_email(update.email)
if not login or (update.email is not None and not new_email):
raise HTTPException(status_code=422, detail="Enter a GitHub login and a valid email address.")
- identity_map: Final[Mapping[str, str]] = MappingProxyType(
- {key: value for key, value in current.identity_map.items() if update.email is None and key != login}
+ identity_map: Final[Mapping[str, str]] = (
+ MappingProxyType({key: value for key, value in current.identity_map.items() if key != login})
if update.email is None
- else {**current.identity_map, login: new_email}
+ else MappingProxyType({**current.identity_map, login: new_email})
)
settings: Final = ROISettings(
github_api_url=current.github_api_url,
@@ -622,9 +624,11 @@ async def reset_roi_calculator_setup(
try:
current: Final = await _load_settings(repository)
stored: Final = await _load_stored_settings(repository)
- settings: Final = current.model_copy(update={"repos": ()})
+ settings: Final = current.model_copy(update=MappingProxyType({"repos": ()}))
await _save_settings(repository, settings, stored.github_token, stored.estimator_key)
await store.clear_report()
return _public_settings(settings)
finally:
- await store.finish(owner, status.model_copy(update={"running": False, "phase": "idle", "stage": "Idle"}))
+ await store.finish(
+ owner, status.model_copy(update=MappingProxyType({"running": False, "phase": "idle", "stage": "Idle"}))
+ )
diff --git a/litellm/proxy/roi_calculator/estimator.py b/litellm/proxy/roi_calculator/estimator.py
index db0102b7c46..200cc38f5c6 100644
--- a/litellm/proxy/roi_calculator/estimator.py
+++ b/litellm/proxy/roi_calculator/estimator.py
@@ -3,6 +3,7 @@ import json
from collections.abc import Awaitable
from typing import Final, Literal, Protocol, TypeAlias
+import httpx
from pydantic import ValidationError
from typing_extensions import NotRequired, ReadOnly, TypedDict
@@ -174,7 +175,7 @@ class Estimator:
if choice.finish_reason not in (None, "stop") or choice.message.content is None:
raise ValueError("incomplete estimator response")
result: Final = ROIEstimatorResult.model_validate_json(extract_classifier_json(choice.message.content))
- except Exception:
+ except (httpx.HTTPError, ValueError, IndexError):
raise SourceError(
"The estimator did not return valid hours and reasoning. Check the selected model and prompt."
) from None
diff --git a/litellm/proxy/roi_calculator/github.py b/litellm/proxy/roi_calculator/github.py
index f2ddcef3ecf..4ee837498dc 100644
--- a/litellm/proxy/roi_calculator/github.py
+++ b/litellm/proxy/roi_calculator/github.py
@@ -9,7 +9,9 @@ import httpx
from pydantic import BaseModel, ConfigDict, Field, TypeAdapter
from typing_extensions import ReadOnly, TypedDict
-from litellm.llms.custom_httpx.http_handler import get_async_httpx_client
+from litellm.llms.custom_httpx.http_handler import (
+ get_async_httpx_client, # pyright: ignore[reportUnknownVariableType] # shared client factory has untyped params
+)
from litellm.proxy.roi_calculator.analytics import normalize_email
from litellm.types.llms.custom_http import httpxSpecialProvider
from litellm.types.roi_calculator import ROIPullCommit, ROIPullEvidence, ROIPullFile, ROISettings
@@ -273,7 +275,7 @@ async def _fetch_page(
)
try:
parsed: Final[tuple[_T, ...]] = adapter.validate_python(response.json())
- except Exception:
+ except ValueError:
raise SourceError(error_message) from None
return parsed, 'rel="next"' in response.headers.get("link", "")
@@ -325,11 +327,9 @@ class GitHub:
else MappingProxyType({"Accept": "application/vnd.github+json"})
)
self._api_url: Final = settings.github_api_url.rstrip("/")
- client_params: Final[dict[str, object]] = {
- "timeout": 45,
- "follow_redirects": False,
- **({"transport": transport} if transport is not None else {}),
- }
+ client_params: Final = TypeAdapter(dict[str, object]).validate_python(
+ MappingProxyType({"timeout": 45, "follow_redirects": False, "transport": transport})
+ )
self.client: Final[httpx.AsyncClient] = (
client
if client is not None
@@ -395,8 +395,8 @@ class GitHub:
later_matches, later_has_more = await search_pages(github_page + 1, pages_remaining - 1)
return (*matches, *later_matches), later_has_more
- matches, has_more = await search_pages(first_github_page, _REPOSITORY_SEARCH_PAGES)
- return _repository_values(matches), has_more
+ matches, search_has_more = await search_pages(first_github_page, _REPOSITORY_SEARCH_PAGES)
+ return _repository_values(matches), search_has_more
async def test_repositories(self, repos: tuple[str, ...]) -> None:
for repo in repos:
@@ -439,7 +439,7 @@ class GitHub:
)
try:
detail: Final = _PullDetail.model_validate(detail_response.json())
- except Exception:
+ except ValueError:
raise SourceError("GitHub returned unexpected pull request details.") from None
login: Final = detail.user.login if detail.user and detail.user.login else "deleted-user"
@@ -510,7 +510,7 @@ class GitHub:
return ""
profile: Final = _GitHubUserProfile.model_validate(response.json())
return normalize_email(profile.email)
- except Exception:
+ except (httpx.HTTPError, ValueError):
return ""
async def _commit_metadata(
@@ -585,7 +585,7 @@ class GitHub:
connection: Final = pull_request.commits
except SourceError:
raise
- except Exception:
+ except ValueError:
raise SourceError("GitHub returned unexpected commit metadata.") from None
new_commits: Final[tuple[ROIPullCommit, ...]] = tuple(
_graphql_commit_evidence(node) for node in connection.nodes
diff --git a/litellm/proxy/roi_calculator/sync.py b/litellm/proxy/roi_calculator/sync.py
index 5ef0649e89a..893b11ac007 100644
--- a/litellm/proxy/roi_calculator/sync.py
+++ b/litellm/proxy/roi_calculator/sync.py
@@ -53,10 +53,10 @@ class _DailySpendTable(Protocol):
async def group_by(
self,
*,
- by: list[Literal["user_id", "date"]],
- sum: dict[str, object],
- where: dict[str, object],
- order: dict[str, object],
+ by: Sequence[Literal["user_id", "date"]],
+ sum: Mapping[str, object],
+ where: Mapping[str, object],
+ order: Mapping[str, object],
) -> Sequence[Mapping[str, object]]: ...
@@ -121,18 +121,18 @@ async def read_spend(
database: Final = prisma_client.db
daily_table: Final = database.litellm_dailyuserspend
- group_by: Final[list[Literal["user_id", "date"]]] = [
- "user_id",
- "date",
- ]
- sums: Final[dict[str, object]] = {"spend": True, "api_requests": True}
- date_filter: Final[dict[str, object]] = {
- "date": {
- "gte": start.isoformat(),
- "lte": end.isoformat(),
- }
- }
- order: Final[dict[str, object]] = {"date": "asc"}
+ group_by: Final = TypeAdapter(list[Literal["user_id", "date"]]).validate_python(("user_id", "date"))
+ sums: Final = _JSON_OBJECT_ADAPTER.validate_python(MappingProxyType({"spend": True, "api_requests": True}))
+ date_filter: Final = _JSON_OBJECT_ADAPTER.validate_python(
+ MappingProxyType(
+ {
+ "date": _JSON_OBJECT_ADAPTER.validate_python(
+ MappingProxyType({"gte": start.isoformat(), "lte": end.isoformat()})
+ )
+ }
+ )
+ )
+ order: Final = _JSON_OBJECT_ADAPTER.validate_python(MappingProxyType({"date": "asc"}))
groups: Final = _DAILY_SPEND_GROUPS.validate_python(
await daily_table.group_by(
by=group_by,
@@ -309,6 +309,20 @@ class SyncManager:
await self._coordinator.finish(self._owner, self.status)
return True
+ async def _heartbeat(
+ self, task: asyncio.Task[object] | None, coordinator: SyncCoordinator | None, owner: str
+ ) -> None:
+ if coordinator is None or task is None:
+ return
+ try:
+ while True:
+ await asyncio.sleep(1)
+ if not await coordinator.heartbeat(owner, self.status):
+ task.cancel()
+ return
+ except Exception: # noqa: BLE001 - any coordination failure must stop a worker before its lease expires
+ task.cancel()
+
async def _run(
self,
settings: ROISettings,
@@ -320,21 +334,7 @@ class SyncManager:
coordinator: SyncCoordinator | None,
owner: str,
) -> None:
- task: Final = asyncio.current_task()
-
- async def heartbeat() -> None:
- if coordinator is None or task is None:
- return
- try:
- while True:
- await asyncio.sleep(1)
- if not await coordinator.heartbeat(owner, self.status):
- task.cancel()
- return
- except Exception:
- task.cancel()
-
- monitor: Final = asyncio.create_task(heartbeat())
+ monitor: Final = asyncio.create_task(self._heartbeat(asyncio.current_task(), coordinator, owner))
github: Final = self._github_factory(settings, github_transport)
try:
end: Final = self._clock().date()
@@ -384,13 +384,17 @@ class SyncManager:
):
profile: Final = await github.profile_email(cached_pull["login"])
cached_record: Final = TypeAdapter(ROIPullRecord).validate_python(
- {
- **self._cached_record(cached_pull),
- "profile_email": profile,
- "emails": tuple(
- sorted(frozenset(email for email in (*cached_pull["commit_emails"], profile) if email))
- ),
- }
+ MappingProxyType(
+ {
+ **self._cached_record(cached_pull),
+ "profile_email": profile,
+ "emails": tuple(
+ sorted(
+ frozenset(email for email in (*cached_pull["commit_emails"], profile) if email)
+ )
+ ),
+ }
+ )
)
self._update_estimate_progress(cached_record["estimate"])
return index, cached_record
@@ -453,7 +457,7 @@ class SyncManager:
warnings=(),
)
await github.close()
- report_json: Final[dict[str, object]] = _JSON_OBJECT_ADAPTER.validate_python(
+ report_json: Final[Mapping[str, object]] = _JSON_OBJECT_ADAPTER.validate_python(
_REPORT_ADAPTER.dump_python(report, mode="json")
)
monitor.cancel()
@@ -482,7 +486,7 @@ class SyncManager:
raise
except SourceError as exc:
self._update_status(phase="error", stage="Sync failed", error=str(exc))
- except Exception:
+ except Exception: # noqa: BLE001 - background job boundary records a safe failure for every source error
self._update_status(
phase="error",
stage="Sync failed",
@@ -505,7 +509,10 @@ class SyncManager:
if coordinator is not None and self._status.phase != "complete":
await coordinator.finish(owner, self.status)
- def _update_status(self, **update: Unpack[_StatusUpdate]) -> None:
+ def _update_status(
+ self,
+ **update: Unpack[_StatusUpdate], # kwargs-ok: Unpack preserves the typed status update contract
+ ) -> None:
status: Final = ROISyncStatus.model_validate(MappingProxyType({**self._status.model_dump(), **update}))
self._status = status
@@ -515,7 +522,7 @@ class SyncManager:
return None
try:
return _REPORT_ADAPTER.validate_python(parameter.param_value)
- except Exception:
+ except ValueError:
return None
def _cached_record(self, pull: ROIPullRecord) -> ROIPullRecord:
diff --git a/litellm/proxy/roi_calculator/sync_store.py b/litellm/proxy/roi_calculator/sync_store.py
index 5c4ed03eeed..75c0af3ea28 100644
--- a/litellm/proxy/roi_calculator/sync_store.py
+++ b/litellm/proxy/roi_calculator/sync_store.py
@@ -1,6 +1,6 @@
-import json
from datetime import datetime
-from typing import Final, Protocol, cast
+from types import MappingProxyType
+from typing import Final, Protocol, cast # noqa: TID251 - PrismaWrapper dynamically delegates database methods
from pydantic import BaseModel, ConfigDict, TypeAdapter
@@ -11,9 +11,15 @@ _SYNC_KEY: Final = "roi_calculator_sync"
_REPORT_KEY: Final = "roi_calculator_report"
+class _SyncState(BaseModel):
+ owner: str
+ status: ROISyncStatus
+ cancel: bool = False
+
+
class _StateRow(BaseModel):
model_config = ConfigDict(extra="ignore")
- param_value: dict[str, object]
+ param_value: _SyncState
expired: bool = False
last_run_at: datetime
@@ -38,7 +44,7 @@ class SyncStore:
AND ($3::text::double precision = 0 OR "LiteLLM_Config".last_run_at <= NOW() - $3::text::double precision * INTERVAL '1 minute')
RETURNING param_name""",
_SYNC_KEY,
- json.dumps({"owner": owner, "status": status.model_dump(), "cancel": False}),
+ _SyncState(owner=owner, status=status).model_dump_json(),
str(scheduled_interval),
)
return bool(rows)
@@ -75,9 +81,12 @@ class SyncStore:
DELETE FROM "LiteLLM_Config" cached
WHERE starts_with(cached.param_name, 'roi_calculator_pull_')
AND EXISTS (SELECT 1 FROM owned) AND $4::text IS NOT NULL
- AND NOT EXISTS (
+ AND EXISTS (
SELECT 1 FROM jsonb_array_elements($4::jsonb->'pulls') pull
- WHERE cached.param_name = 'roi_calculator_pull_' || (pull->>'cache_key')
+ WHERE pull->>'url' = cached.param_value->>'url'
+ AND pull->'estimate'->>'status' = 'estimated'
+ AND pull->>'cache_key' IS NOT NULL
+ AND cached.param_name <> 'roi_calculator_pull_' || (pull->>'cache_key')
)
)
UPDATE "LiteLLM_Config" SET param_value = jsonb_set(param_value, '{status}', $3::jsonb),
@@ -101,16 +110,18 @@ class SyncStore:
)
if not rows:
return None
- status: Final = ROISyncStatus.model_validate(rows[0].param_value["status"])
+ status: Final = rows[0].param_value.status
if rows[0].expired and status.running:
return status.model_copy(
- update={
- "running": False,
- "phase": "error",
- "finished_at": rows[0].last_run_at.isoformat(),
- "stage": "Sync interrupted",
- "error": "The worker stopped responding. Run analysis again to resume saved estimates.",
- }
+ update=MappingProxyType(
+ {
+ "running": False,
+ "phase": "error",
+ "finished_at": rows[0].last_run_at.isoformat(),
+ "stage": "Sync interrupted",
+ "error": "The worker stopped responding. Run analysis again to resume saved estimates.",
+ }
+ )
)
return status
diff --git a/tests/integration/database/test_roi_sync_store.py b/tests/integration/database/test_roi_sync_store.py
new file mode 100644
index 00000000000..57cbcea3539
--- /dev/null
+++ b/tests/integration/database/test_roi_sync_store.py
@@ -0,0 +1,87 @@
+from datetime import datetime, timezone
+from types import MappingProxyType
+from typing import Final
+
+import pytest
+from pydantic import TypeAdapter
+
+from litellm.proxy.common_utils.user_api_key_cache import UserApiKeyCache
+from litellm.proxy.roi_calculator.sample import sample_report
+from litellm.proxy.roi_calculator.sync_store import SyncStore
+from litellm.proxy.utils import PrismaClient, ProxyLogging
+from litellm.types.roi_calculator import ROIPullRecord, ROIReport, ROISyncStatus
+from tests.integration._support.database import read_rows, scratch_database, write_rows
+
+
+@pytest.mark.asyncio
+async def test_roi_cache_survives_scope_changes_and_uses_writer(monkeypatch: pytest.MonkeyPatch) -> None:
+ with scratch_database() as writer_url, scratch_database() as reader_url:
+ write_rows(
+ 'CREATE TABLE "LiteLLM_Config" (param_name TEXT PRIMARY KEY, param_value JSONB NOT NULL, '
+ "last_run_at TIMESTAMP NOT NULL DEFAULT NOW())",
+ (),
+ database_url=writer_url,
+ )
+ monkeypatch.setenv("DATABASE_URL", writer_url)
+ # The reader deliberately has no table: any accidental replica read fails
+ monkeypatch.setenv("DATABASE_URL_READ_REPLICA", reader_url)
+ client: Final = PrismaClient(writer_url, ProxyLogging(UserApiKeyCache()))
+ await client.connect()
+ try:
+ store: Final = SyncStore(client)
+ report: Final = sample_report(datetime(2026, 9, 30, tzinfo=timezone.utc))
+ pull: Final[ROIPullRecord] = {
+ **report["pulls"][0],
+ "url": "https://github.com/example/repo/pull/1",
+ "cache_key": "new",
+ }
+ for key, url in (("old", pull["url"]), ("new", pull["url"]), ("outside-window", "other-pr")):
+ value: ROIPullRecord = {**pull, "url": url, "cache_key": key}
+ write_rows(
+ 'INSERT INTO "LiteLLM_Config" (param_name, param_value) VALUES (%s, %s::jsonb)',
+ (f"roi_calculator_pull_{key}", TypeAdapter(ROIPullRecord).dump_json(value).decode()),
+ database_url=writer_url,
+ )
+ running: Final = ROISyncStatus(
+ running=True,
+ phase="estimates",
+ stage="Estimating",
+ done=0,
+ total=1,
+ estimated=0,
+ reused=0,
+ needs_attention=0,
+ error=None,
+ )
+ complete: Final = running.model_copy(update=MappingProxyType({"running": False, "phase": "complete"}))
+ narrowed: Final[ROIReport] = {**report, "pulls": (pull,)}
+ empty: Final[ROIReport] = {**report, "pulls": ()}
+ assert await store.acquire("worker", running)
+ assert not await store.acquire("other-worker", running)
+ observed: Final = await store.status()
+ assert observed is not None and observed.running
+ assert await store.heartbeat("worker", running)
+ assert await store.finish("worker", complete, narrowed)
+ assert tuple(
+ row["param_name"]
+ for row in read_rows(
+ 'SELECT param_name FROM "LiteLLM_Config" WHERE starts_with(param_name, %s) ORDER BY param_name',
+ ("roi_calculator_pull_",),
+ database_url=writer_url,
+ )
+ ) == ("roi_calculator_pull_new", "roi_calculator_pull_outside-window")
+ assert not await store.acquire("scheduled", running, 1440)
+ assert await store.acquire("manual", running)
+ assert await store.finish("manual", complete, empty)
+ assert (
+ len(
+ read_rows(
+ 'SELECT param_name FROM "LiteLLM_Config" WHERE starts_with(param_name, %s)',
+ ("roi_calculator_pull_",),
+ database_url=writer_url,
+ )
+ )
+ == 2
+ )
+ finally:
+ await client.disconnect()
diff --git a/ui/litellm-dashboard/src/components/leftnav.tsx b/ui/litellm-dashboard/src/components/leftnav.tsx
index ad423d65bd6..1af6095413a 100644
--- a/ui/litellm-dashboard/src/components/leftnav.tsx
+++ b/ui/litellm-dashboard/src/components/leftnav.tsx
@@ -24,6 +24,7 @@ import {
import {
Activity,
BarChart3,
+ Calculator,
Bell,
Blocks,
Bot,
@@ -218,7 +219,7 @@ const menuGroups: MenuGroup[] = [
{
key: "roi-calculator",
page: "roi-calculator",
- icon: ,
+ icon: ,
roles: all_admin_roles,
label: (
diff --git a/uv.lock b/uv.lock
index bc07e3dc863..2d31641a5e3 100644
--- a/uv.lock
+++ b/uv.lock
@@ -7857,14 +7857,14 @@ wheels = [
[[package]]
name = "pyjwt"
-version = "2.14.0"
+version = "2.15.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "typing-extensions", marker = "python_full_version < '3.11'" },
]
-sdist = { url = "https://files.pythonhosted.org/packages/af/c3/8a3b59c25070cc61dc517fbdfa5dc0904670c96f605cc69759dc09166b99/pyjwt-2.14.0.tar.gz", hash = "sha256:77283c83fb56ecf566a886c757a714bc83668e38156de2cce8263302f42e0b86", size = 113177, upload-time = "2026-09-11T13:11:54.638Z" }
+sdist = { url = "https://files.pythonhosted.org/packages/02/a5/5197bfd06417837ac079921c66fa6393f1dea3557272a263cebfef69e432/pyjwt-2.15.0.tar.gz", hash = "sha256:b11c5f9791d7bf51c2b39a81ed669f6b2dbbd669df2942f6c60167e9e3d1abe4", size = 120513, upload-time = "2026-09-23T16:56:00.689Z" }
wheels = [
- { url = "https://files.pythonhosted.org/packages/9c/97/672cb32ce0dfea44b740cb7b4f97038463b9cf7c0ead1aacf595572851d6/pyjwt-2.14.0-py3-none-any.whl", hash = "sha256:ad0cef71c756a56e74863c2919cf0985f72decbcfcb550ee2f422e7c62b5eedc", size = 32896, upload-time = "2026-09-11T13:11:53.409Z" },
+ { url = "https://files.pythonhosted.org/packages/e8/55/40e45bf052ee8ee12a4dfd785519660f8effa7b065442b91646ec6828619/pyjwt-2.15.0-py3-none-any.whl", hash = "sha256:7a3742debf6b879e912dbb9819ceec1594be812452b78c5f2e2dfc56564954f8", size = 33680, upload-time = "2026-09-23T16:55:59.241Z" },
]
[package.optional-dependencies]