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
+56
View File
@@ -0,0 +1,56 @@
from datetime import datetime, timezone
from fastapi.testclient import TestClient
from app.config import Settings
from app.main import create_app
from app.models import ProviderUsage
from app.providers.base import UsageProvider
class StubProvider(UsageProvider):
def __init__(self, provider: str, status: str):
super().__init__(provider, 1, 'test')
self.status = status
async def _fetch_usage(self):
return ProviderUsage(provider=self.provider, status=self.status, source='test',
last_successful_update=datetime.now(timezone.utc) if self.status == 'ok' else None)
def test_health_index_and_demo_usage():
settings = Settings(demo_mode=True)
with TestClient(create_app(settings)) as client:
assert client.get('/health').json() == {'status': 'ok'}
index = client.get('/')
assert index.status_code == 200
assert 'AI USAGE' in index.text
asset = client.get('/static/style.css')
assert asset.status_code == 200
assert 'provider-card' in asset.text
usage = client.get('/api/usage')
assert usage.status_code == 200
assert usage.headers['cache-control'] == 'no-store'
body = usage.json()
assert body['demo_mode'] is True
assert body['codex']['status'] == 'ok'
assert body['devin']['status'] == 'ok'
datetime.fromisoformat(body['server_time'])
def test_provider_errors_are_independent():
settings = Settings(codex_timeout=1, devin_timeout=1)
providers = (StubProvider('codex', 'ok'), StubProvider('devin', 'error'))
with TestClient(create_app(settings, providers)) as client:
body = client.get('/api/usage').json()
assert body['codex']['status'] == 'ok'
assert body['devin']['status'] == 'error'
assert body['devin']['error'] == 'Unable to retrieve usage information'
def test_disabled_provider_is_normalized():
settings = Settings(demo_mode=True, codex_enabled=False)
with TestClient(create_app(settings)) as client:
body = client.get('/api/usage').json()
assert body['codex']['status'] == 'disabled'
assert body['codex']['limits'] == []
+53
View File
@@ -0,0 +1,53 @@
from datetime import datetime, timezone
import pytest
from app.config import Settings
from app.providers.codex import CodexProvider, parse_codex_result
def payload():
return {'result': {'rateLimits': {'limitName': 'Plan', 'primary': {
'usedPercent': 27, 'windowDurationMins': 300, 'resetsAt': 1735689600}, 'secondary': {
'usedPercent': 54, 'windowDurationMins': 10080, 'resetsAt': 1735689600},
'credits': {'hasCredits': True, 'unlimited': False, 'balance': '12.5'}}}}
def test_codex_primary_secondary_and_credits():
limits = parse_codex_result(payload(), 'UTC', datetime(2025, 1, 1, tzinfo=timezone.utc))
assert [limit.name for limit in limits] == ['5 Hour Limit', 'Weekly Limit', 'Additional Credits']
assert limits[0].remaining_percent == 73
assert limits[1].used_percent == 54
assert limits[2].remaining == 12.5
def test_rate_limit_map_is_preferred_without_fallback_duplicate():
data = payload()
data['result']['rateLimitsByLimitId'] = {'mapped': data['result']['rateLimits']}
data['result']['rateLimits'] = {'primary': {'usedPercent': 1, 'windowDurationMins': 300}}
limits = parse_codex_result(data, 'UTC')
assert len(limits) == 3
assert all(limit.id.startswith('mapped-') for limit in limits)
def test_empty_rate_limit_map_falls_back_to_snapshot():
data = payload()
data['result']['rateLimitsByLimitId'] = {}
limits = parse_codex_result(data, 'UTC')
assert [limit.name for limit in limits] == ['5 Hour Limit', 'Weekly Limit', 'Additional Credits']
assert all(limit.id.startswith('rateLimits-') for limit in limits)
def test_malformed_percentage_is_rejected():
data = payload()
data['result']['rateLimits']['primary']['usedPercent'] = 140
with pytest.raises(ValueError):
parse_codex_result(data, 'UTC')
@pytest.mark.asyncio
async def test_missing_codex_executable_is_generic():
provider = CodexProvider(Settings(codex_command='definitely-not-a-codex-executable'))
result = await provider.get_usage()
assert result.status == 'error'
assert result.error == 'Unable to retrieve usage information'
+125
View File
@@ -0,0 +1,125 @@
import time
import httpx
import pytest
from app.config import Settings
from app.providers.devin import DevinProvider
def make_client(handler):
return httpx.AsyncClient(transport=httpx.MockTransport(handler), base_url='https://api.devin.ai')
@pytest.mark.asyncio
async def test_org_consumption_and_max_limit():
now = int(time.time())
async def handler(request):
if request.url.path.endswith('/cycles'):
return httpx.Response(200, json={'items': [{'after': now - 10, 'before': now + 1000}]})
if '/daily/organizations/' in request.url.path:
return httpx.Response(200, json={'total_acus': 124, 'consumption_by_date': []})
if request.url.path.endswith('/organizations/org/id'):
return httpx.Response(200, json={'max_cycle_acu_limit': 320})
return httpx.Response(404)
client = make_client(handler)
provider = DevinProvider(Settings(devin_org_id='org/id'), client)
result = await provider.get_usage()
assert result.status == 'ok'
limit = result.limits[0]
assert limit.used == 124
assert limit.limit == 320
assert limit.remaining == 196
assert limit.used_percent == pytest.approx(38.75)
await client.aclose()
@pytest.mark.asyncio
async def test_user_scope_wins_over_org_scope():
now = int(time.time())
calls = []
async def handler(request):
calls.append(request.url.path)
if request.url.path.endswith('/cycles'):
return httpx.Response(200, json={'items': [{'after': now - 10, 'before': now + 1000}]})
if '/daily/users/' in request.url.path:
return httpx.Response(200, json={'total_acus': 12, 'consumption_by_date': []})
if '/organizations/' in request.url.path:
return httpx.Response(200, json={'max_cycle_acu_limit': 320})
return httpx.Response(404)
client = make_client(handler)
provider = DevinProvider(Settings(devin_user_id='user/id', devin_org_id='org/id'), client)
result = await provider.get_usage()
assert result.status == 'ok'
assert result.limits[0].limit is None
assert any('/daily/users/' in path for path in calls)
assert not any('/v3/enterprise/organizations/' in path for path in calls)
await client.aclose()
@pytest.mark.asyncio
async def test_cycle_pagination_uses_end_cursor():
now = int(time.time())
cycle_queries = []
async def handler(request):
if request.url.path.endswith('/cycles'):
cycle_queries.append(request.url.params)
if len(cycle_queries) == 1:
return httpx.Response(200, json={'items': [{'after': now - 1000, 'before': now - 100}],
'has_next_page': True, 'end_cursor': 'next'})
return httpx.Response(200, json={'items': [{'after': now - 10, 'before': now + 1000}]})
return httpx.Response(200, json={'total_acus': 12, 'consumption_by_date': []})
client = make_client(handler)
provider = DevinProvider(Settings(), client)
result = await provider.get_usage()
assert result.status == 'ok'
assert len(cycle_queries) == 2
assert cycle_queries[1].get('after') == 'next'
await client.aclose()
@pytest.mark.asyncio
async def test_user_scope_does_not_invent_limit():
now = int(time.time())
async def handler(request):
if request.url.path.endswith('/cycles'):
return httpx.Response(200, json={'items': [{'after': now - 10, 'before': now + 1000}]})
return httpx.Response(200, json={'total_acus': 12, 'consumption_by_date': []})
client = make_client(handler)
provider = DevinProvider(Settings(devin_user_id='user/id'), client)
result = await provider.get_usage()
assert result.status == 'ok'
assert result.limits[0].limit is None
assert result.limits[0].used == 12
await client.aclose()
@pytest.mark.asyncio
async def test_required_forbidden_is_generic():
async def handler(request):
return httpx.Response(403, text='secret upstream body')
client = make_client(handler)
provider = DevinProvider(Settings(), client)
result = await provider.get_usage()
assert result.status == 'error'
assert result.error == 'Unable to retrieve usage information'
assert 'secret' not in result.model_dump_json()
await client.aclose()
@pytest.mark.asyncio
async def test_optional_org_forbidden_keeps_consumption():
now = int(time.time())
async def handler(request):
if request.url.path.endswith('/cycles'):
return httpx.Response(200, json={'items': [{'after': now - 10, 'before': now + 1000}]})
if '/daily/organizations/' in request.url.path:
return httpx.Response(200, json={'total_acus': 12, 'consumption_by_date': []})
return httpx.Response(403, text='no org read')
client = make_client(handler)
provider = DevinProvider(Settings(devin_org_id='org'), client)
result = await provider.get_usage()
assert result.status == 'ok'
assert result.limits[0].used == 12
assert result.limits[0].limit is None
await client.aclose()
+16
View File
@@ -0,0 +1,16 @@
from pathlib import Path
ROOT = Path(__file__).parents[1]
def test_frontend_assets_exist_and_use_api():
html = (ROOT / 'templates/index.html').read_text()
script = (ROOT / 'static/app.js').read_text()
css = (ROOT / 'static/style.css').read_text()
assert '/static/style.css' in html
assert '/static/app.js' in html
assert "fetch('/api/usage'" in script
assert 'entry.id === limit.id' in script
assert 'requestAnimationFrame' in script
assert 'grid-template-columns' in css