mirror of
https://github.com/BerriAI/litellm.git
synced 2026-10-07 02:59:05 +00:00
fix(roi): recover interrupted syncs and resolve review findings
This commit is contained in:
parent
73ed163f33
commit
d1d1673525
12 changed files with 242 additions and 116 deletions
|
|
@ -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"}))
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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.",
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
98
ui/litellm-dashboard/package-lock.json
generated
98
ui/litellm-dashboard/package-lock.json
generated
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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({
|
|||
)}
|
||||
<DialogFooter>
|
||||
{pull.url && (
|
||||
<Button variant="outline" render={<a href={pull.url} target="_blank" rel="noopener noreferrer" />}>
|
||||
<a
|
||||
className={buttonVariants({ variant: "outline" })}
|
||||
href={pull.url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
View on GitHub
|
||||
</Button>
|
||||
</a>
|
||||
)}
|
||||
</DialogFooter>
|
||||
</>
|
||||
|
|
|
|||
|
|
@ -30,6 +30,7 @@ type View = "overview" | "people" | "settings";
|
|||
|
||||
const IDLE_STATUS: ROISyncStatus = {
|
||||
running: false,
|
||||
elapsed_seconds: 0,
|
||||
phase: "idle",
|
||||
stage: "Idle",
|
||||
done: 0,
|
||||
|
|
|
|||
|
|
@ -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"');
|
||||
});
|
||||
|
|
|
|||
|
|
@ -61,7 +61,7 @@ export const filterPulls = (pulls: ROIPull[], query: string): ROIPull[] => {
|
|||
);
|
||||
};
|
||||
|
||||
export const peopleCsv = (summary: ROISummary): string => {
|
||||
export const peopleCsv = (summary: Pick<ROISummary, "people" | "start" | "end" | "effort_basis">): string => {
|
||||
const escape = (value: unknown): string => {
|
||||
const text = value == null ? "" : String(value);
|
||||
const safe = /^[=+@\-\t\r]/.test(text) ? `'${text}` : text;
|
||||
|
|
|
|||
6
uv.lock
generated
6
uv.lock
generated
|
|
@ -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]]
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue