chore: initial project setup with AI usage dashboard for Codex and Devin
Add FastAPI-based monitoring dashboard with support for Codex CLI and Devin API v3 Enterprise consumption tracking. Includes Docker deployment, systemd service configuration, kiosk mode launcher, comprehensive documentation, and demo mode for testing without credentials.
This commit is contained in:
@@ -0,0 +1,52 @@
|
||||
import asyncio
|
||||
import time
|
||||
from abc import ABC, abstractmethod
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from app.models import ProviderUsage
|
||||
|
||||
|
||||
GENERIC_ERROR = 'Unable to retrieve usage information'
|
||||
|
||||
|
||||
def utc_now() -> datetime:
|
||||
return datetime.now(timezone.utc)
|
||||
|
||||
|
||||
class UsageProvider(ABC):
|
||||
def __init__(self, provider: str, cache_ttl: float, source: str | None = None):
|
||||
self.provider = provider
|
||||
self.cache_ttl = cache_ttl
|
||||
self.source = source
|
||||
self.last_successful_update: datetime | None = None
|
||||
self._cached: ProviderUsage | None = None
|
||||
self._cache_time = 0.0
|
||||
self._lock = asyncio.Lock()
|
||||
|
||||
async def get_usage(self) -> ProviderUsage:
|
||||
if self._cached is not None and time.monotonic() - self._cache_time < self.cache_ttl:
|
||||
return self._cached.model_copy(deep=True)
|
||||
async with self._lock:
|
||||
if self._cached is not None and time.monotonic() - self._cache_time < self.cache_ttl:
|
||||
return self._cached.model_copy(deep=True)
|
||||
try:
|
||||
result = await self._fetch_usage()
|
||||
if result.status == 'ok':
|
||||
self.last_successful_update = result.last_successful_update or utc_now()
|
||||
result.last_successful_update = self.last_successful_update
|
||||
self._cached = result.model_copy(deep=True)
|
||||
self._cache_time = time.monotonic()
|
||||
return result
|
||||
result.last_successful_update = self.last_successful_update
|
||||
result.error = GENERIC_ERROR
|
||||
return result
|
||||
except Exception:
|
||||
return ProviderUsage(provider=self.provider, status='error', error=GENERIC_ERROR,
|
||||
last_successful_update=self.last_successful_update, source=self.source)
|
||||
|
||||
@abstractmethod
|
||||
async def _fetch_usage(self) -> ProviderUsage:
|
||||
raise NotImplementedError
|
||||
|
||||
async def aclose(self) -> None:
|
||||
return None
|
||||
@@ -0,0 +1,226 @@
|
||||
import asyncio
|
||||
import json
|
||||
import math
|
||||
from datetime import datetime, timezone
|
||||
from decimal import Decimal, InvalidOperation
|
||||
from typing import Any
|
||||
from zoneinfo import ZoneInfo
|
||||
|
||||
from app.config import Settings
|
||||
from app.models import ProviderUsage, UsageLimit
|
||||
from app.providers.base import GENERIC_ERROR, UsageProvider, utc_now
|
||||
|
||||
|
||||
def _number(value: Any, *, minimum: float = 0, maximum: float | None = None) -> float | None:
|
||||
if isinstance(value, bool) or not isinstance(value, (int, float)):
|
||||
return None
|
||||
try:
|
||||
number = float(value)
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
if not math.isfinite(number) or number < minimum or (maximum is not None and number > maximum):
|
||||
return None
|
||||
return number
|
||||
|
||||
|
||||
def _decimal(value: Any, *, minimum: float = 0) -> float | None:
|
||||
if value is None or isinstance(value, bool):
|
||||
return None
|
||||
try:
|
||||
number = float(Decimal(str(value)))
|
||||
except (InvalidOperation, ValueError, TypeError):
|
||||
return None
|
||||
if not math.isfinite(number) or number < minimum:
|
||||
return None
|
||||
return number
|
||||
|
||||
|
||||
def _reset(value: Any, timezone_name: str) -> datetime | None:
|
||||
epoch = _number(value, minimum=0)
|
||||
if epoch is None:
|
||||
return None
|
||||
try:
|
||||
return datetime.fromtimestamp(epoch, timezone.utc).astimezone(ZoneInfo(timezone_name))
|
||||
except (OverflowError, OSError, ValueError):
|
||||
return None
|
||||
|
||||
|
||||
def _duration_name(minutes: Any) -> str:
|
||||
duration = _number(minutes, minimum=0)
|
||||
if duration is None:
|
||||
return 'Usage Limit'
|
||||
if duration == 300:
|
||||
return '5 Hour Limit'
|
||||
if duration == 1440:
|
||||
return 'Daily Limit'
|
||||
if duration == 10080:
|
||||
return 'Weekly Limit'
|
||||
if duration < 60:
|
||||
label, amount = 'Minute', duration
|
||||
elif duration < 1440:
|
||||
label, amount = 'Hour', duration / 60
|
||||
else:
|
||||
label, amount = 'Day', duration / 1440
|
||||
rendered = str(int(amount)) if amount == int(amount) else f'{amount:g}'
|
||||
return f'{rendered} {label} Limit'
|
||||
|
||||
|
||||
def _window_limit(snapshot: dict[str, Any], key: str, window: Any, updated: datetime,
|
||||
timezone_name: str, suffix: str) -> UsageLimit | None:
|
||||
if not isinstance(window, dict):
|
||||
return None
|
||||
used = _number(window.get('usedPercent'), minimum=0, maximum=100)
|
||||
if used is None:
|
||||
return None
|
||||
duration = window.get('windowDurationMins')
|
||||
name = _duration_name(duration)
|
||||
limit_name = snapshot.get('limitName')
|
||||
if name == 'Usage Limit' and isinstance(limit_name, str) and limit_name.strip():
|
||||
name = limit_name.strip()
|
||||
return UsageLimit(id=f'{key}-{suffix}', name=name, used_percent=used,
|
||||
remaining_percent=100 - used, reset_at=_reset(window.get('resetsAt'), timezone_name),
|
||||
window=name.removesuffix(' Limit').lower(), updated_at=updated)
|
||||
|
||||
|
||||
def parse_codex_result(payload: Any, timezone_name: str, now: datetime | None = None) -> list[UsageLimit]:
|
||||
if not isinstance(payload, dict) or not isinstance(payload.get('result'), dict):
|
||||
raise ValueError
|
||||
result = payload['result']
|
||||
snapshots: list[tuple[str, dict[str, Any]]] = []
|
||||
mapped = result.get('rateLimitsByLimitId')
|
||||
if isinstance(mapped, dict) and mapped:
|
||||
if any(not isinstance(value, dict) for value in mapped.values()):
|
||||
raise ValueError
|
||||
snapshots = [(str(key), value) for key, value in mapped.items()]
|
||||
elif isinstance(result.get('rateLimits'), dict):
|
||||
snapshots = [('rateLimits', result['rateLimits'])]
|
||||
else:
|
||||
raise ValueError
|
||||
updated = now or utc_now()
|
||||
if updated.tzinfo is None:
|
||||
updated = updated.replace(tzinfo=timezone.utc)
|
||||
limits: list[UsageLimit] = []
|
||||
for key, snapshot in snapshots:
|
||||
for suffix, field in (('primary', 'primary'), ('secondary', 'secondary')):
|
||||
window = snapshot.get(field)
|
||||
if window is not None and not isinstance(window, dict):
|
||||
raise ValueError
|
||||
if isinstance(window, dict):
|
||||
parsed = _window_limit(snapshot, key, window, updated, timezone_name, suffix)
|
||||
if parsed is None:
|
||||
raise ValueError
|
||||
limits.append(parsed)
|
||||
credits = snapshot.get('credits')
|
||||
if credits is not None and not isinstance(credits, dict):
|
||||
raise ValueError
|
||||
if isinstance(credits, dict):
|
||||
if not isinstance(credits.get('hasCredits'), bool) or not isinstance(credits.get('unlimited'), bool):
|
||||
raise ValueError
|
||||
if isinstance(credits, dict) and credits.get('hasCredits') is True:
|
||||
balance = None if credits.get('unlimited') is True else _decimal(credits.get('balance'))
|
||||
if credits.get('unlimited') is not True and credits.get('balance') is not None and balance is None:
|
||||
raise ValueError
|
||||
limits.append(UsageLimit(id=f'{key}-credits', name='Additional Credits', remaining=balance,
|
||||
unit='credits', updated_at=updated))
|
||||
individual = snapshot.get('individualLimit')
|
||||
if individual is not None and not isinstance(individual, dict):
|
||||
raise ValueError
|
||||
if isinstance(individual, dict):
|
||||
total = _decimal(individual.get('limit'))
|
||||
used = _decimal(individual.get('used'))
|
||||
remaining_percent = _number(individual.get('remainingPercent'), minimum=0, maximum=100)
|
||||
if total is None or used is None or remaining_percent is None or used > total:
|
||||
raise ValueError
|
||||
limits.append(UsageLimit(id=f'{key}-individual', name='Spend Limit', used_percent=100 - remaining_percent,
|
||||
remaining_percent=remaining_percent, used=used, remaining=total - used,
|
||||
limit=total, unit=None, reset_at=_reset(individual.get('resetsAt'), timezone_name),
|
||||
window='individual', updated_at=updated))
|
||||
if not limits:
|
||||
raise ValueError
|
||||
return limits
|
||||
|
||||
|
||||
class CodexProvider(UsageProvider):
|
||||
def __init__(self, settings: Settings):
|
||||
super().__init__('codex', settings.provider_cache_ttl, 'Codex app-server')
|
||||
self.settings = settings
|
||||
|
||||
async def _fetch_usage(self) -> ProviderUsage:
|
||||
process = None
|
||||
stderr_task = None
|
||||
try:
|
||||
process = await asyncio.create_subprocess_exec(
|
||||
self.settings.codex_command, 'app-server', '--listen', 'stdio://',
|
||||
stdin=asyncio.subprocess.PIPE, stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE)
|
||||
stderr_task = asyncio.create_task(self._discard(process.stderr))
|
||||
async with asyncio.timeout(self.settings.codex_timeout):
|
||||
await self._send(process, {'method': 'initialize', 'id': 1, 'params': {
|
||||
'clientInfo': {'name': 'ai_usage_dashboard', 'title': 'AI Usage Dashboard', 'version': '1.0.0'},
|
||||
'capabilities': {'optOutNotificationMethods': []}}})
|
||||
await self._read_response(process.stdout, 1)
|
||||
await self._send(process, {'method': 'initialized'})
|
||||
await self._send(process, {'method': 'account/rateLimits/read', 'id': 2})
|
||||
response = await self._read_response(process.stdout, 2)
|
||||
limits = parse_codex_result(response, self.settings.timezone)
|
||||
return ProviderUsage(provider='codex', status='ok', limits=limits,
|
||||
last_successful_update=utc_now(), source=self.source)
|
||||
except Exception:
|
||||
return ProviderUsage(provider='codex', status='error', error=GENERIC_ERROR,
|
||||
last_successful_update=self.last_successful_update, source=self.source)
|
||||
finally:
|
||||
if process is not None:
|
||||
try:
|
||||
await self._close_process(process)
|
||||
except Exception:
|
||||
pass
|
||||
if stderr_task is not None:
|
||||
if not stderr_task.done():
|
||||
stderr_task.cancel()
|
||||
await asyncio.gather(stderr_task, return_exceptions=True)
|
||||
|
||||
@staticmethod
|
||||
async def _discard(stream: asyncio.StreamReader) -> None:
|
||||
while await stream.read(8192):
|
||||
pass
|
||||
|
||||
@staticmethod
|
||||
async def _send(process: asyncio.subprocess.Process, message: dict[str, Any]) -> None:
|
||||
process.stdin.write((json.dumps(message, separators=(',', ':')) + '\n').encode())
|
||||
await process.stdin.drain()
|
||||
|
||||
@staticmethod
|
||||
async def _read_response(stdout: asyncio.StreamReader, expected_id: int) -> dict[str, Any]:
|
||||
while True:
|
||||
line = await stdout.readline()
|
||||
if not line:
|
||||
raise ValueError
|
||||
try:
|
||||
message = json.loads(line)
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
raise ValueError
|
||||
if isinstance(message, dict) and message.get('id') == expected_id:
|
||||
if 'result' not in message or not isinstance(message['result'], dict):
|
||||
raise ValueError
|
||||
return message
|
||||
|
||||
@staticmethod
|
||||
async def _close_process(process: asyncio.subprocess.Process) -> None:
|
||||
if process.returncode is not None:
|
||||
return
|
||||
try:
|
||||
process.terminate()
|
||||
except ProcessLookupError:
|
||||
pass
|
||||
try:
|
||||
await asyncio.wait_for(process.wait(), timeout=0.5)
|
||||
except asyncio.TimeoutError:
|
||||
try:
|
||||
process.kill()
|
||||
except ProcessLookupError:
|
||||
return
|
||||
try:
|
||||
await process.wait()
|
||||
except ProcessLookupError:
|
||||
pass
|
||||
except ProcessLookupError:
|
||||
pass
|
||||
@@ -0,0 +1,31 @@
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from zoneinfo import ZoneInfo
|
||||
|
||||
from app.config import Settings
|
||||
from app.models import ProviderUsage, UsageLimit
|
||||
from app.providers.base import UsageProvider, utc_now
|
||||
|
||||
|
||||
class DemoProvider(UsageProvider):
|
||||
def __init__(self, provider: str, settings: Settings):
|
||||
super().__init__(provider, settings.provider_cache_ttl, 'Demo data')
|
||||
self.settings = settings
|
||||
|
||||
async def _fetch_usage(self) -> ProviderUsage:
|
||||
now = utc_now().astimezone(ZoneInfo(self.settings.timezone))
|
||||
if self.provider == 'codex':
|
||||
limits = [
|
||||
UsageLimit(id='demo-5-hour', name='5 Hour Limit', used_percent=27, remaining_percent=73,
|
||||
reset_at=now + timedelta(hours=2, minutes=15), window='5 hour', updated_at=now),
|
||||
UsageLimit(id='demo-weekly', name='Weekly Limit', used_percent=54, remaining_percent=46,
|
||||
reset_at=now + timedelta(days=4), window='weekly', updated_at=now),
|
||||
]
|
||||
else:
|
||||
limit, used = 320.0, 124.0
|
||||
next_month = (now.replace(day=28) + timedelta(days=4)).replace(day=1, hour=0, minute=0, second=0, microsecond=0)
|
||||
limits = [UsageLimit(id='demo-cycle', name='Cycle ACU Usage', used=used, limit=limit,
|
||||
remaining=limit - used, used_percent=used / limit * 100,
|
||||
remaining_percent=(limit - used) / limit * 100, unit='ACUs',
|
||||
reset_at=next_month, window='billing cycle', updated_at=now)]
|
||||
return ProviderUsage(provider=self.provider, status='ok', limits=limits,
|
||||
last_successful_update=now, source=self.source)
|
||||
@@ -0,0 +1,129 @@
|
||||
import math
|
||||
import time
|
||||
from datetime import datetime
|
||||
from typing import Any
|
||||
from urllib.parse import quote
|
||||
from zoneinfo import ZoneInfo
|
||||
|
||||
import httpx
|
||||
from pydantic import BaseModel, ConfigDict, Field, StrictBool, StrictInt, StrictStr, ValidationError
|
||||
|
||||
from app.config import Settings
|
||||
from app.models import ProviderUsage, UsageLimit
|
||||
from app.providers.base import GENERIC_ERROR, UsageProvider, utc_now
|
||||
|
||||
|
||||
class Cycle(BaseModel):
|
||||
model_config = ConfigDict(extra='ignore')
|
||||
after: StrictInt
|
||||
before: StrictInt
|
||||
|
||||
|
||||
class CyclesResponse(BaseModel):
|
||||
model_config = ConfigDict(extra='ignore')
|
||||
items: list[Cycle]
|
||||
has_next_page: StrictBool = False
|
||||
end_cursor: StrictStr | None = None
|
||||
|
||||
|
||||
class DailyResponse(BaseModel):
|
||||
model_config = ConfigDict(extra='ignore')
|
||||
total_acus: Any
|
||||
consumption_by_date: list[Any] = Field(default_factory=list)
|
||||
|
||||
|
||||
class OrgResponse(BaseModel):
|
||||
model_config = ConfigDict(extra='ignore')
|
||||
max_cycle_acu_limit: Any = None
|
||||
|
||||
|
||||
def _acu(value: Any) -> float:
|
||||
if isinstance(value, bool) or not isinstance(value, (int, float)):
|
||||
raise ValueError
|
||||
number = float(value)
|
||||
if not math.isfinite(number) or number < 0:
|
||||
raise ValueError
|
||||
return number
|
||||
|
||||
|
||||
class DevinProvider(UsageProvider):
|
||||
def __init__(self, settings: Settings, client: httpx.AsyncClient | None = None):
|
||||
super().__init__('devin', settings.provider_cache_ttl, 'Devin API v3')
|
||||
self.settings = settings
|
||||
self._client = client or httpx.AsyncClient(base_url=settings.devin_api_url.rstrip('/'), timeout=settings.devin_timeout,
|
||||
headers={'Authorization': f'Bearer {settings.devin_api_key.get_secret_value()}'})
|
||||
self._owns_client = client is None
|
||||
|
||||
async def _fetch_usage(self) -> ProviderUsage:
|
||||
now = int(time.time())
|
||||
params = {'first': 100}
|
||||
seen_cursors: set[str] = set()
|
||||
cycle = None
|
||||
for page in range(100):
|
||||
cycles_response = await self._client.get('/v3/enterprise/consumption/cycles', params=params)
|
||||
cycles_response.raise_for_status()
|
||||
try:
|
||||
cycles_page = CyclesResponse.model_validate(cycles_response.json())
|
||||
except (ValidationError, ValueError, TypeError):
|
||||
raise ValueError
|
||||
cycle = next((item for item in cycles_page.items if item.after <= now < item.before), None)
|
||||
if cycle is not None:
|
||||
break
|
||||
if not cycles_page.has_next_page:
|
||||
break
|
||||
cursor = cycles_page.end_cursor
|
||||
if not cursor or cursor in seen_cursors:
|
||||
raise ValueError
|
||||
seen_cursors.add(cursor)
|
||||
params = {'first': 100, 'after': cursor}
|
||||
else:
|
||||
raise ValueError
|
||||
if cycle is None:
|
||||
raise ValueError
|
||||
user_id = self.settings.devin_user_id.strip()
|
||||
org_id = self.settings.devin_org_id.strip()
|
||||
if user_id:
|
||||
path = f'/v3/enterprise/consumption/daily/users/{quote(user_id, safe="")}'
|
||||
org_scope = False
|
||||
elif org_id:
|
||||
path = f'/v3/enterprise/consumption/daily/organizations/{quote(org_id, safe="")}'
|
||||
org_scope = True
|
||||
else:
|
||||
path = '/v3/enterprise/consumption/daily'
|
||||
org_scope = False
|
||||
daily_response = await self._client.get(path, params={'time_after': cycle.after, 'time_before': min(now, cycle.before)})
|
||||
daily_response.raise_for_status()
|
||||
try:
|
||||
daily = DailyResponse.model_validate(daily_response.json())
|
||||
used = _acu(daily.total_acus)
|
||||
except (ValidationError, ValueError, TypeError):
|
||||
raise ValueError
|
||||
limit = remaining = used_percent = remaining_percent = None
|
||||
if org_scope:
|
||||
org_path = f'/v3/enterprise/organizations/{quote(org_id, safe="")}'
|
||||
try:
|
||||
org_response = await self._client.get(org_path)
|
||||
if org_response.is_success:
|
||||
org_data = OrgResponse.model_validate(org_response.json())
|
||||
if org_data.max_cycle_acu_limit is not None:
|
||||
limit = _acu(org_data.max_cycle_acu_limit)
|
||||
if limit > 0:
|
||||
remaining = max(limit - used, 0)
|
||||
used_percent = min(used / limit * 100, 100)
|
||||
remaining_percent = max(100 - used_percent, 0)
|
||||
else:
|
||||
remaining = 0
|
||||
used_percent = 100 if used else 0
|
||||
remaining_percent = 0 if used else 100
|
||||
except (httpx.HTTPError, ValidationError, ValueError, TypeError):
|
||||
pass
|
||||
updated = utc_now()
|
||||
return ProviderUsage(provider='devin', status='ok', limits=[UsageLimit(
|
||||
id='cycle-acu-usage', name='Cycle ACU Usage', used=used, remaining=remaining, limit=limit,
|
||||
used_percent=used_percent, remaining_percent=remaining_percent, unit='ACUs',
|
||||
reset_at=datetime.fromtimestamp(cycle.before, tz=ZoneInfo(self.settings.timezone)),
|
||||
window='billing cycle', updated_at=updated)], last_successful_update=updated, source=self.source)
|
||||
|
||||
async def aclose(self) -> None:
|
||||
if self._owns_client:
|
||||
await self._client.aclose()
|
||||
Reference in New Issue
Block a user