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,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'] == []
|
||||
@@ -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'
|
||||
@@ -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()
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user