fix(roi): recover interrupted syncs and resolve review findings

This commit is contained in:
moe-berri 2026-09-30 10:20:42 -07:00
parent 73ed163f33
commit d1d1673525
12 changed files with 242 additions and 116 deletions

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -30,6 +30,7 @@ type View = "overview" | "people" | "settings";
const IDLE_STATUS: ROISyncStatus = {
running: false,
elapsed_seconds: 0,
phase: "idle",
stage: "Idle",
done: 0,

View file

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

View file

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

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