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:
2026-09-18 11:34:33 +02:00
commit 58eab24258
24 changed files with 1309 additions and 0 deletions
View File
+52
View File
@@ -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
+226
View File
@@ -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
+31
View File
@@ -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)
+129
View File
@@ -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()