From d1d1673525fdd326b618d45d2b999ad00f1224c0 Mon Sep 17 00:00:00 2001 From: moe-berri Date: Wed, 30 Sep 2026 10:20:42 -0700 Subject: [PATCH] fix(roi): recover interrupted syncs and resolve review findings --- .../roi_calculator_endpoints.py | 49 ++++++---- litellm/proxy/roi_calculator/sync.py | 60 +++++++----- litellm/proxy/roi_calculator/sync_store.py | 28 +++--- .../interactions/test_openapi_compliance.py | 15 +++ tests/unit/proxy/roi_calculator/test_sync.py | 59 +++++++++++ ui/litellm-dashboard/package-lock.json | 98 +++++++++---------- ui/litellm-dashboard/package.json | 4 +- .../_components/ROICalculatorDialogs.tsx | 11 ++- .../_components/ROICalculatorView.tsx | 1 + .../_components/roiCalculatorData.test.ts | 25 +++++ .../_components/roiCalculatorData.ts | 2 +- uv.lock | 6 +- 12 files changed, 242 insertions(+), 116 deletions(-) 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 && ( - + )} 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]]