diff --git a/litellm/proxy/management_endpoints/roi_calculator_endpoints.py b/litellm/proxy/management_endpoints/roi_calculator_endpoints.py
index d35da2870c1..8134aa811f9 100644
--- a/litellm/proxy/management_endpoints/roi_calculator_endpoints.py
+++ b/litellm/proxy/management_endpoints/roi_calculator_endpoints.py
@@ -1,14 +1,18 @@
from collections.abc import Mapping, Sequence
from datetime import date, datetime, timedelta, timezone
from enum import Enum
+from functools import lru_cache
from types import MappingProxyType
from typing import Annotated, Final, Literal
import httpx
-from fastapi import APIRouter, Depends, HTTPException, Query
+from fastapi import APIRouter, Depends, FastAPI, HTTPException, Query
from pydantic import BaseModel, ConfigDict, Field, SecretStr, TypeAdapter, ValidationError
-from litellm.llms.custom_httpx.http_handler import AsyncHTTPHandler
+from litellm.llms.custom_httpx.http_handler import (
+ AsyncHTTPHandler,
+ get_async_httpx_client, # pyright: ignore[reportUnknownVariableType] # shared client factory has untyped params
+)
from litellm.proxy._types import CommonProxyErrors, LitellmUserRoles, UserAPIKeyAuth
from litellm.proxy.auth.user_api_key_auth import user_api_key_auth
from litellm.proxy.common_utils.encrypt_decrypt_utils import decrypt_value_helper, encrypt_value_helper
@@ -247,24 +251,31 @@ def _gateway_key(settings: ROISettings) -> str:
return credential
-def _completion_caller(settings: ROISettings) -> CompletionCaller:
+def _gateway_http_client() -> AsyncHTTPHandler:
from litellm.proxy.proxy_server import app
+ return get_async_httpx_client(
+ llm_provider="roi_calculator",
+ params={"transport": _gateway_transport(app), "timeout": 180, "follow_redirects": False},
+ )
+
+
+@lru_cache(maxsize=1)
+def _gateway_transport(app: FastAPI) -> httpx.ASGITransport:
+ return httpx.ASGITransport(app=app)
+
+
+def _completion_caller(settings: ROISettings) -> CompletionCaller:
credential: Final = _gateway_key(settings)
- transport: Final = httpx.ASGITransport(app=app)
async def complete(request: ROICompletionRequest) -> object:
- client: Final = AsyncHTTPHandler(transport=transport, timeout=180, follow_redirects=False)
- try:
- response: Final = await client.client.post(
- "http://litellm.internal/v1/chat/completions",
- headers=MappingProxyType({"authorization": f"Bearer {credential}", "content-type": "application/json"}),
- content=request.model_dump_json(exclude_none=True),
- )
- response.raise_for_status()
- return TypeAdapter(object).validate_python(response.json())
- finally:
- await client.close()
+ response: Final = await _gateway_http_client().client.post(
+ "http://litellm.internal/v1/chat/completions",
+ headers=MappingProxyType({"authorization": f"Bearer {credential}", "content-type": "application/json"}),
+ content=request.model_dump_json(exclude_none=True),
+ )
+ response.raise_for_status()
+ return TypeAdapter(object).validate_python(response.json())
return complete
@@ -278,10 +289,8 @@ class _GatewayModels(BaseModel):
async def _test_estimator_access(settings: ROISettings) -> None:
- from litellm.proxy.proxy_server import app
-
credential: Final = _gateway_key(settings)
- client: Final = AsyncHTTPHandler(transport=httpx.ASGITransport(app=app), timeout=30, follow_redirects=False)
+ client: Final = _gateway_http_client()
try:
response: Final = await client.client.get(
"http://litellm.internal/v1/models",
@@ -293,8 +302,6 @@ async def _test_estimator_access(settings: ROISettings) -> None:
raise HTTPException(status_code=409, detail="The estimator key cannot access the selected model.")
except (httpx.HTTPError, ValidationError):
raise HTTPException(status_code=409, detail="The estimator key could not connect to the gateway.") from None
- finally:
- await client.close()
def _spend_reader(repository: ConfigRepository) -> SpendReader:
@@ -617,7 +624,7 @@ async def reset_roi_calculator_setup(
stored: Final = await _load_stored_settings(repository)
settings: Final = current.model_copy(update={"repos": ()})
await _save_settings(repository, settings, stored.github_token, stored.estimator_key)
- await repository.prisma_client.db.execute_raw('DELETE FROM "LiteLLM_Config" WHERE param_name = $1', _REPORT_KEY)
+ await store.clear_report()
return _public_settings(settings)
finally:
await store.finish(owner, status.model_copy(update={"running": False, "phase": "idle", "stage": "Idle"}))
diff --git a/litellm/proxy/roi_calculator/sync.py b/litellm/proxy/roi_calculator/sync.py
index 61c9d1523cb..5ef0649e89a 100644
--- a/litellm/proxy/roi_calculator/sync.py
+++ b/litellm/proxy/roi_calculator/sync.py
@@ -43,6 +43,7 @@ class _ReportRepository(Protocol):
class SyncCoordinator(Protocol):
+ async def status(self) -> ROISyncStatus | None: ...
async def acquire(self, owner: str, status: ROISyncStatus, scheduled_interval: float = 0) -> bool: ...
async def heartbeat(self, owner: str, status: ROISyncStatus) -> bool: ...
async def finish(self, owner: str, status: ROISyncStatus, report: ROIReport | None = None) -> bool: ...
@@ -231,6 +232,7 @@ class SyncManager:
self._task: asyncio.Task[None] | None = None
self._coordinator: SyncCoordinator | None = None
self._owner: str = ""
+ self._start_lock: Final = asyncio.Lock()
@property
def status(self) -> ROISyncStatus:
@@ -259,32 +261,40 @@ class SyncManager:
coordinator: SyncCoordinator | None = None,
scheduled_interval: float = 0,
) -> bool:
- if self._status.running or not settings.repos or not settings.estimator_model:
- return False
- initial_status: Final = ROISyncStatus(
- running=True,
- started_at=self._clock().isoformat(),
- phase="spend",
- stage="Reading gateway spend",
- done=0,
- total=0,
- estimated=0,
- reused=0,
- needs_attention=0,
- error=None,
- )
- owner: Final = str(uuid4())
- if coordinator is not None and not await coordinator.acquire(owner, initial_status, scheduled_interval):
- return False
- self._status = initial_status
- self._coordinator = coordinator
- self._owner = owner
- self._task = asyncio.create_task(
- self._run(
- settings, repository, spend_reader, complete, github_transport, estimator_models, coordinator, owner
+ async with self._start_lock:
+ if not settings.repos or not settings.estimator_model:
+ return False
+ if self._status.running:
+ if coordinator is None:
+ return False
+ shared: Final = await coordinator.status()
+ if shared is not None and shared.running:
+ return False
+ await self.cancel()
+ initial_status: Final = ROISyncStatus(
+ running=True,
+ started_at=self._clock().isoformat(),
+ phase="spend",
+ stage="Reading gateway spend",
+ done=0,
+ total=0,
+ estimated=0,
+ reused=0,
+ needs_attention=0,
+ error=None,
)
- )
- return True
+ owner: Final = str(uuid4())
+ if coordinator is not None and not await coordinator.acquire(owner, initial_status, scheduled_interval):
+ return False
+ self._status = initial_status
+ self._coordinator = coordinator
+ self._owner = owner
+ self._task = asyncio.create_task(
+ self._run(
+ settings, repository, spend_reader, complete, github_transport, estimator_models, coordinator, owner
+ )
+ )
+ return True
async def cancel(self) -> bool:
task: Final = self._task
diff --git a/litellm/proxy/roi_calculator/sync_store.py b/litellm/proxy/roi_calculator/sync_store.py
index 760991d3219..5c4ed03eeed 100644
--- a/litellm/proxy/roi_calculator/sync_store.py
+++ b/litellm/proxy/roi_calculator/sync_store.py
@@ -1,5 +1,6 @@
import json
-from typing import Final, Protocol, runtime_checkable
+from datetime import datetime
+from typing import Final, Protocol, cast
from pydantic import BaseModel, ConfigDict, TypeAdapter
@@ -14,23 +15,17 @@ class _StateRow(BaseModel):
model_config = ConfigDict(extra="ignore")
param_value: dict[str, object]
expired: bool = False
+ last_run_at: datetime
-@runtime_checkable
class _SyncDatabase(Protocol):
async def query_raw(self, query: str, *args: object) -> object: ...
async def execute_raw(self, query: str, *args: object) -> int: ...
-def _sync_database(database: object) -> _SyncDatabase:
- if not isinstance(database, _SyncDatabase):
- raise TypeError("The database does not support sync coordination.")
- return database
-
-
class SyncStore:
def __init__(self, prisma: PrismaClient) -> None:
- self._db: Final = _sync_database(prisma.db)
+ self._db: Final = cast(_SyncDatabase, prisma.writer_db) # cast-ok: PrismaWrapper delegates methods dynamically
async def acquire(self, owner: str, status: ROISyncStatus, scheduled_interval: float = 0) -> bool:
rows: Final = await self._db.query_raw(
@@ -40,11 +35,11 @@ class SyncStore:
SET param_value = EXCLUDED.param_value, last_run_at = NOW()
WHERE ("LiteLLM_Config".last_run_at < NOW() - INTERVAL '60 seconds'
OR "LiteLLM_Config".param_value->'status'->>'running' = 'false')
- AND ($3::float = 0 OR "LiteLLM_Config".last_run_at <= NOW() - $3::float * INTERVAL '1 minute')
+ 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}),
- scheduled_interval,
+ str(scheduled_interval),
)
return bool(rows)
@@ -76,6 +71,14 @@ class SyncStore:
INSERT INTO "LiteLLM_Config" (param_name, param_value)
SELECT $5, $4::jsonb FROM owned WHERE $4::text IS NOT NULL
ON CONFLICT (param_name) DO UPDATE SET param_value = EXCLUDED.param_value
+ ), cache_cleanup AS (
+ 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 (
+ SELECT 1 FROM jsonb_array_elements($4::jsonb->'pulls') pull
+ WHERE cached.param_name = 'roi_calculator_pull_' || (pull->>'cache_key')
+ )
)
UPDATE "LiteLLM_Config" SET param_value = jsonb_set(param_value, '{status}', $3::jsonb),
last_run_at = NOW()
@@ -91,7 +94,7 @@ class SyncStore:
async def status(self) -> ROISyncStatus | None:
rows: Final = TypeAdapter(tuple[_StateRow, ...]).validate_python(
await self._db.query_raw(
- """SELECT param_value, last_run_at < NOW() - INTERVAL '60 seconds' AS expired
+ """SELECT param_value, last_run_at, last_run_at < NOW() - INTERVAL '60 seconds' AS expired
FROM "LiteLLM_Config" WHERE param_name = $1""",
_SYNC_KEY,
)
@@ -104,6 +107,7 @@ class SyncStore:
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.",
}
diff --git a/tests/unit/interactions/test_openapi_compliance.py b/tests/unit/interactions/test_openapi_compliance.py
index 4378c4d9739..e05c18602ab 100644
--- a/tests/unit/interactions/test_openapi_compliance.py
+++ b/tests/unit/interactions/test_openapi_compliance.py
@@ -16,6 +16,9 @@ import httpx
import pytest
from openapi_core import OpenAPI
+from litellm.llms.gemini.interactions.transformation import GoogleAIStudioInteractionsConfig
+from litellm.types.router import GenericLiteLLMParams
+
OPENAPI_SPEC_URL = "https://ai.google.dev/static/api/interactions.openapi.json"
@@ -79,6 +82,18 @@ class TestRequestCompliance:
assert "model" in schema["properties"]
assert "input" in schema["properties"]
+ request: Final = GoogleAIStudioInteractionsConfig().transform_request(
+ model="gemini/test-model",
+ agent=None,
+ input="Hello",
+ optional_params={},
+ litellm_params=GenericLiteLLMParams(),
+ headers={},
+ )
+ assert request["model"] == "test-model"
+ assert request["input"] == "Hello"
+ assert "model" in schema.get("required", ())
+
# Check our supported optional fields exist in spec
our_optional_fields = [
"tools",
diff --git a/tests/unit/proxy/roi_calculator/test_sync.py b/tests/unit/proxy/roi_calculator/test_sync.py
index 6576a995ba3..0d3ddffa3a2 100644
--- a/tests/unit/proxy/roi_calculator/test_sync.py
+++ b/tests/unit/proxy/roi_calculator/test_sync.py
@@ -16,6 +16,7 @@ from litellm.types.roi_calculator import (
ROIReport,
ROISettings,
ROISpendRecord,
+ ROISyncStatus,
)
_PULL_LIST_JSON: Final = """[
@@ -352,3 +353,61 @@ async def test_saved_estimates_survive_report_reset() -> None:
await _wait_until_finished(restarted)
assert restarted.status.phase == "complete"
assert restarted.status.reused == 1
+
+
+class _LeaseCoordinator:
+ def __init__(self) -> None:
+ self.current: ROISyncStatus | None = None
+ self.owner: str | None = None
+
+ async def status(self) -> ROISyncStatus | None:
+ return self.current
+
+ async def acquire(self, owner: str, status: ROISyncStatus, scheduled_interval: float = 0) -> bool:
+ if self.current is not None and self.current.running:
+ return False
+ self.owner = owner
+ self.current = status
+ return True
+
+ async def heartbeat(self, owner: str, status: ROISyncStatus) -> bool:
+ return self.owner == owner and self.current is not None and self.current.running
+
+ async def finish(self, owner: str, status: ROISyncStatus, report: ROIReport | None = None) -> bool:
+ if self.owner != owner:
+ return False
+ self.current = status
+ return True
+
+
+@pytest.mark.asyncio
+async def test_expired_lease_can_restart_without_restarting_the_gateway() -> None:
+ coordinator: Final = _LeaseCoordinator()
+ entered: Final = asyncio.Event()
+ cancelled: Final = asyncio.Event()
+ manager: Final = SyncManager(clock=_fixed_now)
+ repository: Final = _ReportRepository()
+
+ async def blocked_completion(request: ROICompletionRequest) -> object:
+ entered.set()
+ try:
+ await asyncio.Event().wait()
+ finally:
+ cancelled.set()
+
+ assert await manager.start(
+ _settings(), repository, _spend_reader(), blocked_completion, _transport(), coordinator=coordinator
+ )
+ await entered.wait()
+ assert not await manager.start(
+ _settings(), repository, _spend_reader(), _completion(), _transport(), coordinator=coordinator
+ )
+ assert coordinator.current is not None
+ coordinator.current = coordinator.current.model_copy(update={"running": False, "phase": "error"})
+ assert await manager.start(
+ _settings(), repository, _spend_reader(), _completion(), _transport(), coordinator=coordinator
+ )
+ await _wait_until_finished(manager)
+ assert cancelled.is_set()
+ assert manager.status.phase == "complete"
+ assert manager.status.estimated == 1
diff --git a/ui/litellm-dashboard/package-lock.json b/ui/litellm-dashboard/package-lock.json
index 0b71b51dc3f..36191c68d31 100644
--- a/ui/litellm-dashboard/package-lock.json
+++ b/ui/litellm-dashboard/package-lock.json
@@ -25,7 +25,7 @@
"jwt-decode": "4.0.0",
"lucide-react": "0.513.0",
"moment": "2.30.1",
- "next": "16.3.3",
+ "next": "16.3.6",
"next-themes": "^0.4.6",
"nuqs": "^2.9.4",
"openai": "4.104.0",
@@ -62,7 +62,7 @@
"@vitest/coverage-v8": "4.1.11",
"@vitest/ui": "4.1.11",
"eslint": "9.39.2",
- "eslint-config-next": "16.3.3",
+ "eslint-config-next": "16.3.6",
"eslint-config-prettier": "10.1.8",
"eslint-plugin-jest-dom": "5.10.1",
"eslint-plugin-testing-library": "7.16.2",
@@ -2061,15 +2061,15 @@
}
},
"node_modules/@next/env": {
- "version": "16.3.3",
- "resolved": "https://registry.npmjs.org/@next/env/-/env-16.3.3.tgz",
- "integrity": "sha512-U2eYQRwXj+dsqxV79zFqExDdatnNY/ZWc2nsJU1p/OgT7fd3dXwlF6OjYaFQCfMoeTA19PWq+wVmYgimVA+V+g==",
+ "version": "16.3.6",
+ "resolved": "https://registry.npmjs.org/@next/env/-/env-16.3.6.tgz",
+ "integrity": "sha512-x9Vblze1EbtltQYnNH38xCPWU3TVfBd1eXqA3+w9+BTpedkkdNpAaltXlGQ/nsc1+E0mVTNrtcbX3GoO09zeLQ==",
"license": "MIT"
},
"node_modules/@next/eslint-plugin-next": {
- "version": "16.3.3",
- "resolved": "https://registry.npmjs.org/@next/eslint-plugin-next/-/eslint-plugin-next-16.3.3.tgz",
- "integrity": "sha512-pbEh30vvjKpDoTAmo1v3q2uM4JUi8QaEBpbmjWvGfoec2jLghy/WNtvzAT0bk+Ik9oz6etjt4YjXEk4BQnicCw==",
+ "version": "16.3.6",
+ "resolved": "https://registry.npmjs.org/@next/eslint-plugin-next/-/eslint-plugin-next-16.3.6.tgz",
+ "integrity": "sha512-jowwDX+7DOlDIjJLgTMxudw+k37QnWu1JkZLkSi9MaJBfDYcfhAPMKBhXL0idYzFN/AGg//axnOR4cLkHX/Rng==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -2078,9 +2078,9 @@
}
},
"node_modules/@next/swc-darwin-arm64": {
- "version": "16.3.3",
- "resolved": "https://registry.npmjs.org/@next/swc-darwin-arm64/-/swc-darwin-arm64-16.3.3.tgz",
- "integrity": "sha512-8Hiv32QJPwdV6KYJ8meR9SBA061tQqnIKTJDocvOXlEQqib0xMFpzArosuffFUUc0sslbh7QQ8a3Yey1QV8EIw==",
+ "version": "16.3.6",
+ "resolved": "https://registry.npmjs.org/@next/swc-darwin-arm64/-/swc-darwin-arm64-16.3.6.tgz",
+ "integrity": "sha512-E/7GEqaUkt8mk/T8v9lAnrhzR06kdq1ZBkC12F8tAMkdIadwNp3H1KqHynDHrpcTlGCUdq/qu6vUL2aYVyYBdw==",
"cpu": [
"arm64"
],
@@ -2094,9 +2094,9 @@
}
},
"node_modules/@next/swc-darwin-x64": {
- "version": "16.3.3",
- "resolved": "https://registry.npmjs.org/@next/swc-darwin-x64/-/swc-darwin-x64-16.3.3.tgz",
- "integrity": "sha512-A1lgKgwVchRYmSe467zdwhxT9040dd8lH+o65sL5Jet8fjB4kegw/rDyPIpYVRb6jAqwXFOJpjIXJLxQKLiE3A==",
+ "version": "16.3.6",
+ "resolved": "https://registry.npmjs.org/@next/swc-darwin-x64/-/swc-darwin-x64-16.3.6.tgz",
+ "integrity": "sha512-yBE893/nDWTlaiBD1p+qgt7NUen4U5R6FXyH0s67Npq1S3E0cVSef1WIXC2xBRgQvwAvJq6DnS6Y6PrY0cy4Ew==",
"cpu": [
"x64"
],
@@ -2110,9 +2110,9 @@
}
},
"node_modules/@next/swc-linux-arm64-gnu": {
- "version": "16.3.3",
- "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-16.3.3.tgz",
- "integrity": "sha512-bf0FIssMFueU2dm7vQEWWxk0c8UjKTdW0yzuh0sQsD8pf1+KCLDdaqhYZNMYGmXwEOiHAUzgBKudovIlcvvBjg==",
+ "version": "16.3.6",
+ "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-16.3.6.tgz",
+ "integrity": "sha512-KJDpjBqBPYlvkivmyrp+Qys6k/7ksbqGQvRVc6ZEGfR+cjQxx+nUkJaWmNZJsmoOrqYNbaXByF8wa0lBwDhB3Q==",
"cpu": [
"arm64"
],
@@ -2129,9 +2129,9 @@
}
},
"node_modules/@next/swc-linux-arm64-musl": {
- "version": "16.3.3",
- "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-16.3.3.tgz",
- "integrity": "sha512-W7viwCk9JY/cAkdz/A273rd5bb3RgT/IHwR7Upv90tunjBWNtAAhGhoecHh+teRNRSinuAFmE+l7fwZ4YKkrXg==",
+ "version": "16.3.6",
+ "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-16.3.6.tgz",
+ "integrity": "sha512-mqNg2K+hvWskSRb/QM+Ix412DvBsuSF0XV+frTSw5vmoucNnIlynFwKYew8D01bfATErMOM7Bujrf0BA5DRKFA==",
"cpu": [
"arm64"
],
@@ -2148,9 +2148,9 @@
}
},
"node_modules/@next/swc-linux-x64-gnu": {
- "version": "16.3.3",
- "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-16.3.3.tgz",
- "integrity": "sha512-0W46zw1N3ODpI6n0GeivHvvob1pooozgZVqy65k0mh4/7vr+FbY9+WpHzNVXjHipJf/A3FDheBG19H1s5A25rA==",
+ "version": "16.3.6",
+ "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-16.3.6.tgz",
+ "integrity": "sha512-nFncBNGAYouRHjRVaITs9beZRfhX4ssVwpnvPIAbkZVH6LtGoAVlH4bJ8Cnf9SOo9bsXgPFer/GdHtEE3JNOkw==",
"cpu": [
"x64"
],
@@ -2167,9 +2167,9 @@
}
},
"node_modules/@next/swc-linux-x64-musl": {
- "version": "16.3.3",
- "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-16.3.3.tgz",
- "integrity": "sha512-H4mBso8ZTMBPtdT0PN0pBx2ayTvQuTuvS6qT13d77yVFJXAPCxkyIhLTmdMaGTJs0krQYI/qpzdHijCeihXhbg==",
+ "version": "16.3.6",
+ "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-16.3.6.tgz",
+ "integrity": "sha512-5Mf3cHDGR/Iz0ng2Bj3zUR3p5QS9YK3Hn2QiAfavFmyF48zwThAjpFoiTKNIcOHLYS4zEk+gzyJ/9deQ2ZB8yQ==",
"cpu": [
"x64"
],
@@ -2186,9 +2186,9 @@
}
},
"node_modules/@next/swc-win32-arm64-msvc": {
- "version": "16.3.3",
- "resolved": "https://registry.npmjs.org/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-16.3.3.tgz",
- "integrity": "sha512-cTMUJpcEGmeywofCUfhR+rSsoE33+rVPnPEYNTNdLNlsOeEg/vktOsKUSTb28vUGqD2jkm4Zaskcwn7OCI6FQg==",
+ "version": "16.3.6",
+ "resolved": "https://registry.npmjs.org/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-16.3.6.tgz",
+ "integrity": "sha512-0jkJy0C2kbrJWTk4YLa3xk80pVBpx8FCHJym7CnUfDAXe/FWv5qT7SQJbR0KuemyxaEDlEx5WT4VQJoTW+/9Qw==",
"cpu": [
"arm64"
],
@@ -2202,9 +2202,9 @@
}
},
"node_modules/@next/swc-win32-x64-msvc": {
- "version": "16.3.3",
- "resolved": "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-16.3.3.tgz",
- "integrity": "sha512-2VR4cTBzHXaBjnGsuH6GyJjENzQOmHeAh11uY1iUhjm3j5dEUrVJuUj+VL78jaGi/Dik8xS76zEj18BsFhlVZQ==",
+ "version": "16.3.6",
+ "resolved": "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-16.3.6.tgz",
+ "integrity": "sha512-/YXjI1e5OXcZ7YpxRwgP/1jAV/SBKTzeVKqN2mk7mLpcICsyn3Gl5+dIfDTJp70M0ccMhyMMRso4v6mPDCGepg==",
"cpu": [
"x64"
],
@@ -6077,13 +6077,13 @@
}
},
"node_modules/eslint-config-next": {
- "version": "16.3.3",
- "resolved": "https://registry.npmjs.org/eslint-config-next/-/eslint-config-next-16.3.3.tgz",
- "integrity": "sha512-teqtsR26tnlfXFHfVLTM/4tzEzU8DMu6GS1sddZzhfGzgd2f2ofbgDUcsk6cssSCzX6Tk6fmWifJcdANSdPJrw==",
+ "version": "16.3.6",
+ "resolved": "https://registry.npmjs.org/eslint-config-next/-/eslint-config-next-16.3.6.tgz",
+ "integrity": "sha512-1Upt3U7BDwU+ilpe2byZjAfts9oNq4d4fv/zXEvs8/4yS+cwOQW/WCxUNy8gCDquX67SzeehDvKblVC6ZBMocQ==",
"dev": true,
"license": "MIT",
"dependencies": {
- "@next/eslint-plugin-next": "16.3.3",
+ "@next/eslint-plugin-next": "16.3.6",
"eslint-import-resolver-node": "^0.3.6",
"eslint-import-resolver-typescript": "^3.5.2",
"eslint-plugin-import": "^2.32.0",
@@ -9712,12 +9712,12 @@
"license": "MIT"
},
"node_modules/next": {
- "version": "16.3.3",
- "resolved": "https://registry.npmjs.org/next/-/next-16.3.3.tgz",
- "integrity": "sha512-tuRTx1nQ/yVw83cwJBo9F+njGUgMn3UHQycreWHB8XsStvvAh1AthbI8/4IpKnFaF58F+iSiHejYOlMQ/eq83g==",
+ "version": "16.3.6",
+ "resolved": "https://registry.npmjs.org/next/-/next-16.3.6.tgz",
+ "integrity": "sha512-L+otWM/aQbYTx98aZhgEoMb4bZAXx1YVW4UMA/vuCyCoWG5HJyZUili8QAkqzrcC+5///tsz3s0M+SlyB5bLMw==",
"license": "MIT",
"dependencies": {
- "@next/env": "16.3.3",
+ "@next/env": "16.3.6",
"@swc/helpers": "0.5.23",
"baseline-browser-mapping": "^2.9.19",
"caniuse-lite": "^1.0.30001579",
@@ -9731,15 +9731,15 @@
"node": ">=20.9.0"
},
"optionalDependencies": {
- "@next/swc-darwin-arm64": "16.3.3",
- "@next/swc-darwin-x64": "16.3.3",
- "@next/swc-linux-arm64-gnu": "16.3.3",
- "@next/swc-linux-arm64-musl": "16.3.3",
- "@next/swc-linux-x64-gnu": "16.3.3",
- "@next/swc-linux-x64-musl": "16.3.3",
- "@next/swc-win32-arm64-msvc": "16.3.3",
- "@next/swc-win32-x64-msvc": "16.3.3",
- "sharp": "^0.35.3"
+ "@next/swc-darwin-arm64": "16.3.6",
+ "@next/swc-darwin-x64": "16.3.6",
+ "@next/swc-linux-arm64-gnu": "16.3.6",
+ "@next/swc-linux-arm64-musl": "16.3.6",
+ "@next/swc-linux-x64-gnu": "16.3.6",
+ "@next/swc-linux-x64-musl": "16.3.6",
+ "@next/swc-win32-arm64-msvc": "16.3.6",
+ "@next/swc-win32-x64-msvc": "16.3.6",
+ "sharp": "^0.35.4"
},
"peerDependencies": {
"@opentelemetry/api": "^1.1.0",
diff --git a/ui/litellm-dashboard/package.json b/ui/litellm-dashboard/package.json
index 233a0e63881..2ee857e1c70 100644
--- a/ui/litellm-dashboard/package.json
+++ b/ui/litellm-dashboard/package.json
@@ -41,7 +41,7 @@
"jwt-decode": "4.0.0",
"lucide-react": "0.513.0",
"moment": "2.30.1",
- "next": "16.3.3",
+ "next": "16.3.6",
"next-themes": "^0.4.6",
"nuqs": "^2.9.4",
"openai": "4.104.0",
@@ -78,7 +78,7 @@
"@vitest/coverage-v8": "4.1.11",
"@vitest/ui": "4.1.11",
"eslint": "9.39.2",
- "eslint-config-next": "16.3.3",
+ "eslint-config-next": "16.3.6",
"eslint-config-prettier": "10.1.8",
"eslint-plugin-jest-dom": "5.10.1",
"eslint-plugin-testing-library": "7.16.2",
diff --git a/ui/litellm-dashboard/src/app/(dashboard)/roi-calculator/_components/ROICalculatorDialogs.tsx b/ui/litellm-dashboard/src/app/(dashboard)/roi-calculator/_components/ROICalculatorDialogs.tsx
index d7f73c84cb1..91626a41268 100644
--- a/ui/litellm-dashboard/src/app/(dashboard)/roi-calculator/_components/ROICalculatorDialogs.tsx
+++ b/ui/litellm-dashboard/src/app/(dashboard)/roi-calculator/_components/ROICalculatorDialogs.tsx
@@ -3,7 +3,7 @@
import React from "react";
import { extractErrorMessage } from "@/utils/errorUtils";
-import { Button } from "@/components/ui/button";
+import { Button, buttonVariants } from "@/components/ui/button";
import {
Dialog,
DialogContent,
@@ -74,9 +74,14 @@ export function PullReasoningDialog({
)}
{pull.url && (
- }>
+
View on GitHub
-
+
)}
>
diff --git a/ui/litellm-dashboard/src/app/(dashboard)/roi-calculator/_components/ROICalculatorView.tsx b/ui/litellm-dashboard/src/app/(dashboard)/roi-calculator/_components/ROICalculatorView.tsx
index b9bf304447c..1d1bfca559c 100644
--- a/ui/litellm-dashboard/src/app/(dashboard)/roi-calculator/_components/ROICalculatorView.tsx
+++ b/ui/litellm-dashboard/src/app/(dashboard)/roi-calculator/_components/ROICalculatorView.tsx
@@ -30,6 +30,7 @@ type View = "overview" | "people" | "settings";
const IDLE_STATUS: ROISyncStatus = {
running: false,
+ elapsed_seconds: 0,
phase: "idle",
stage: "Idle",
done: 0,
diff --git a/ui/litellm-dashboard/src/app/(dashboard)/roi-calculator/_components/roiCalculatorData.test.ts b/ui/litellm-dashboard/src/app/(dashboard)/roi-calculator/_components/roiCalculatorData.test.ts
index 6e1c1790468..5b18e33e911 100644
--- a/ui/litellm-dashboard/src/app/(dashboard)/roi-calculator/_components/roiCalculatorData.test.ts
+++ b/ui/litellm-dashboard/src/app/(dashboard)/roi-calculator/_components/roiCalculatorData.test.ts
@@ -8,6 +8,7 @@ import {
formatMoney,
formatNumber,
formatSyncedAt,
+ peopleCsv,
} from "./roiCalculatorData";
import type { ROIPull } from "./roiCalculatorData";
@@ -66,3 +67,27 @@ describe("ROI calculator display helpers", () => {
expect(filterPulls([matchingPull], "nobody")).toEqual([]);
});
});
+
+it("exports precise spend, cohort eligibility and safely quoted CSV values", () => {
+ const exportSummary = {
+ start: "2026-09-01",
+ end: "2026-09-30",
+ effort_basis: "without_ai",
+ people: [
+ {
+ email: '=HYPERLINK("bad")',
+ logins: ["alice", "bob"],
+ spend: 0.0001,
+ hours: 4,
+ prs: 1,
+ pending_prs: 0,
+ eligible: true,
+ cost_per_hour: 0.000025,
+ },
+ ],
+ };
+ const csv = peopleCsv(exportSummary);
+ expect(csv.split("\r\n")).toHaveLength(2);
+ expect(csv).toContain('"\'=HYPERLINK(""bad"")","alice;bob","0.0001","4","1","0","true","0.000025"');
+ expect(csv).toContain('"2026-09-01","2026-09-30","without_ai"');
+});
diff --git a/ui/litellm-dashboard/src/app/(dashboard)/roi-calculator/_components/roiCalculatorData.ts b/ui/litellm-dashboard/src/app/(dashboard)/roi-calculator/_components/roiCalculatorData.ts
index e7dd5ebe8a3..4e5ff05de60 100644
--- a/ui/litellm-dashboard/src/app/(dashboard)/roi-calculator/_components/roiCalculatorData.ts
+++ b/ui/litellm-dashboard/src/app/(dashboard)/roi-calculator/_components/roiCalculatorData.ts
@@ -61,7 +61,7 @@ export const filterPulls = (pulls: ROIPull[], query: string): ROIPull[] => {
);
};
-export const peopleCsv = (summary: ROISummary): string => {
+export const peopleCsv = (summary: Pick): string => {
const escape = (value: unknown): string => {
const text = value == null ? "" : String(value);
const safe = /^[=+@\-\t\r]/.test(text) ? `'${text}` : text;
diff --git a/uv.lock b/uv.lock
index 527f53bd372..eac50c81d8e 100644
--- a/uv.lock
+++ b/uv.lock
@@ -10196,11 +10196,11 @@ wheels = [
[[package]]
name = "urllib3"
-version = "2.7.0"
+version = "2.8.0"
source = { registry = "https://pypi.org/simple" }
-sdist = { url = "https://files.pythonhosted.org/packages/53/0c/06f8b233b8fd13b9e5ee11424ef85419ba0d8ba0b3138bf360be2ff56953/urllib3-2.7.0.tar.gz", hash = "sha256:231e0ec3b63ceb14667c67be60f2f2c40a518cb38b03af60abc813da26505f4c", size = 433602, upload-time = "2026-05-07T16:13:18.596Z" }
+sdist = { url = "https://files.pythonhosted.org/packages/e3/05/b17359e1cefb4f909b5e40b1b90a496d987258916dbbf88e842c729f510e/urllib3-2.8.0.tar.gz", hash = "sha256:63bf2ead4c879426ebf22ef2a781eeb4aa3b4ae798a0435506f8687fd5bb9b63", size = 458972, upload-time = "2026-09-15T19:29:36.253Z" }
wheels = [
- { url = "https://files.pythonhosted.org/packages/7f/3e/5db95bcf282c52709639744ca2a8b149baccf648e39c8cc87553df9eae0c/urllib3-2.7.0-py3-none-any.whl", hash = "sha256:9fb4c81ebbb1ce9531cce37674bbc6f1360472bc18ca9a553ede278ef7276897", size = 131087, upload-time = "2026-05-07T16:13:17.151Z" },
+ { url = "https://files.pythonhosted.org/packages/92/9d/c4e665119135114480843e7ab388fa94d8480650450e6f8e26b70d323a4c/urllib3-2.8.0-py3-none-any.whl", hash = "sha256:0cf3cae568d36aa9576b28dfb35f11328f1cb974ca7647d9475ebb86c75ac6e3", size = 135717, upload-time = "2026-09-15T19:29:34.577Z" },
]
[[package]]