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
+15
View File
@@ -0,0 +1,15 @@
HOST=0.0.0.0
PORT=8080
CODEX_ENABLED=true
CODEX_COMMAND=codex
CODEX_TIMEOUT=8
DEVIN_ENABLED=true
DEVIN_API_KEY=
DEVIN_API_URL=https://api.devin.ai
DEVIN_ORG_ID=
DEVIN_USER_ID=
DEVIN_TIMEOUT=8
PROVIDER_CACHE_TTL=8
REFRESH_INTERVAL=10
TIMEZONE=Europe/Vienna
DEMO_MODE=false
+13
View File
@@ -0,0 +1,13 @@
.env
.env.*
!.env.example
.venv/
venv/
__pycache__/
*.py[cod]
.pytest_cache/
.mypy_cache/
.coverage
htmlcov/
.idea/
.vscode/
+15
View File
@@ -0,0 +1,15 @@
FROM python:3.12-slim
ENV PYTHONDONTWRITEBYTECODE=1 PYTHONUNBUFFERED=1
WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY app app
COPY templates templates
COPY static static
COPY .env.example .env.example
RUN useradd --create-home --uid 10001 appuser && chown -R appuser:appuser /app
USER appuser
EXPOSE 8080
HEALTHCHECK --interval=30s --timeout=5s --start-period=10s CMD python -c "import urllib.request; urllib.request.urlopen('http://127.0.0.1:8080/health')"
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8080"]
+206
View File
@@ -0,0 +1,206 @@
# AI Usage Dashboard
AI Usage Dashboard is a small FastAPI monitoring kiosk for the official local Codex app-server mechanism and Devin API v3 Enterprise consumption endpoints. Provider refreshes are independent: an unavailable provider never hides a successful result from the other provider.
## Requirements
- Python 3.12
- Codex CLI installed and authenticated locally when Codex data is enabled
- Devin API v3 Enterprise access and a service user with `ViewAccountConsumption` when Devin data is enabled
- Chromium/Chrome only for the optional kiosk launcher
- Docker and Compose are optional
## Install and run directly
Unix:
```sh
python3.12 -m venv .venv
. .venv/bin/activate
python -m pip install -r requirements.txt
cp .env.example .env
python -m app.main
```
`python -m app.main` is the simplest direct command and honors `HOST` and `PORT` from `.env`. The explicit Uvicorn alternative is:
```sh
python -m uvicorn app.main:app --host 0.0.0.0 --port 8080
```
Windows PowerShell:
```powershell
py -3.12 -m venv .venv
.\.venv\Scripts\Activate.ps1
python -m pip install -r requirements.txt
Copy-Item .env.example .env
python -m app.main
```
`python -m app.main` honors `HOST` and `PORT` from `.env`. The explicit Uvicorn alternative is:
```powershell
python -m uvicorn app.main:app --host 0.0.0.0 --port 8080
```
Open `http://localhost:8080`. The application also runs in deterministic demo mode with `DEMO_MODE=true` and no provider credentials.
## Configuration
Copy `.env.example` to `.env`. Unknown environment variables are ignored and names are case-insensitive.
| Variable | Default | Meaning |
| --- | --- | --- |
| `HOST` | `0.0.0.0` | Bind host when using a launcher that reads settings |
| `PORT` | `8080` | Application port |
| `CODEX_ENABLED` | `true` | Enable the local Codex provider |
| `CODEX_COMMAND` | `codex` | One executable path; it is never shell-split |
| `CODEX_TIMEOUT` | `8` | Codex request timeout in seconds |
| `DEVIN_ENABLED` | `true` | Enable Devin |
| `DEVIN_API_KEY` | empty | Devin Bearer token, kept as a secret value |
| `DEVIN_API_URL` | `https://api.devin.ai` | Devin API base URL |
| `DEVIN_ORG_ID` | empty | Organization scope, used when no user scope is configured |
| `DEVIN_USER_ID` | empty | User scope, takes precedence over organization |
| `DEVIN_TIMEOUT` | `8` | Devin HTTP timeout in seconds |
| `PROVIDER_CACHE_TTL` | `8` | Successful provider cache duration in seconds |
| `REFRESH_INTERVAL` | `10` | Browser refresh period in seconds |
| `TIMEZONE` | `Europe/Vienna` | ZoneInfo name used for reset timestamps |
| `DEMO_MODE` | `false` | Use deterministic local demo values for both cards |
## Provider mechanisms
### Codex
The dashboard invokes the official local CLI exactly as `codex app-server --listen stdio://`, without a shell. It sends the official line-delimited `initialize`, `initialized`, and `account/rateLimits/read` messages and parses primary and secondary windows, credits, and individual limits. Install and log in to the official CLI first:
```sh
codex login
```
The app-server interface is official, but is documented as a development/debug interface and may change. If the executable is not on the service `PATH`, configure `CODEX_COMMAND` with its absolute path. ChatGPT plan rate limits are distinct from OpenAI API billing. `CODEX_API_KEY` is intentionally not used or included because an API key does not expose ChatGPT Codex plan limits.
### Devin
The provider uses the official Devin API v3 Enterprise consumption endpoints. It selects the active cycle from `/v3/enterprise/consumption/cycles`, then requests daily consumption using user scope, organization scope, or the account endpoint in that precedence order. User and organization IDs are URL-quoted. Organization usage additionally attempts the organization max cycle ACU limit; an unavailable optional organization-details request does not discard consumption.
Devin v3 consumption is Enterprise-only and requires a service user with `ViewAccountConsumption`. Reading the organization max limit may require extra organization-read permissions. Self-serve has no supported machine-readable provider endpoint; use demo mode until Devin publishes an official endpoint. Do not claim real-time freshness beyond vendor behavior: the dashboard cache and refresh schedule only control when it asks the vendors again.
## Security and behavior
- The Devin Bearer token is read from environment settings. Codex uses the official CLI's local credential store instead of an API key setting. Neither credential reaches the browser, and neither is returned, logged, or included in error messages.
- Upstream error bodies and process stderr are discarded; users see only a generic provider error.
- Provider calls have independent timeouts and successful results use a monotonic TTL cache. Only successful results are cached.
- The browser makes one `/api/usage` request immediately and repeats using the response refresh interval. A network failure keeps the previous cards visible and marks the header connection state.
- `GET /health` returns exactly `{"status":"ok"}`.
- `GET /api/usage` returns normalized Codex and Devin usage, `server_time`, `demo_mode`, and `refresh_interval`; it sends `Cache-Control: no-store`.
- `GET /` serves the dashboard HTML with `Cache-Control: no-store`. Static assets are under `/static`.
## Docker Compose
Create `.env`, then build and run:
```sh
cp .env.example .env
docker compose up --build -d
curl http://localhost:8080/health
```
The container uses Python 3.12 slim, a non-root user, read-only root storage, a `/tmp` tmpfs, `no-new-privileges`, and a healthcheck. `docker-compose.yml` uses `restart: unless-stopped` and maps port 8080. The image does not bundle the Codex CLI or a personal Codex credential store: Docker works out of the box for demo mode and Devin, while real Codex should normally run directly on the authenticated host. An operator may deliberately build and securely provision the official CLI and credential store, but credentials should never be baked into an image.
## Kiosk mode
`start-kiosk.sh` accepts `DASHBOARD_URL` (default `http://localhost:8080`) and searches in this order: `chromium`, `chromium-browser`, `google-chrome`, `google-chrome-stable`. It launches the first available browser with:
```text
--kiosk --noerrdialogs --disable-infobars --disable-session-crashed-bubble
```
The script exits clearly if no supported browser is installed.
### systemd dashboard service
Install the project at `/opt/usage-kiosk`, create `/opt/usage-kiosk/.env`, and adjust `User` if necessary:
```ini
[Unit]
Description=AI Usage Dashboard
After=network-online.target
Wants=network-online.target
[Service]
Type=simple
User=usagekiosk
Group=usagekiosk
WorkingDirectory=/opt/usage-kiosk
EnvironmentFile=/opt/usage-kiosk/.env
ExecStart=/opt/usage-kiosk/.venv/bin/uvicorn app.main:app --host 0.0.0.0 --port 8080
Restart=on-failure
RestartSec=5
NoNewPrivileges=true
PrivateTmp=true
[Install]
WantedBy=multi-user.target
```
Save as `/etc/systemd/system/usage-kiosk.service`, then run `sudo systemctl daemon-reload`, `sudo systemctl enable --now usage-kiosk`, and inspect `sudo journalctl -u usage-kiosk` if needed.
### systemd browser launch
For systems where the display manager and X authority are ready before the browser unit starts, save this as `/etc/systemd/system/usage-kiosk-browser.service`:
```ini
[Unit]
Description=AI Usage Dashboard Chromium Kiosk
After=display-manager.service usage-kiosk.service
Requires=usage-kiosk.service
[Service]
Type=simple
User=usagekiosk
WorkingDirectory=/opt/usage-kiosk
Environment=DISPLAY=:0
Environment=XAUTHORITY=/home/usagekiosk/.Xauthority
ExecStart=/opt/usage-kiosk/start-kiosk.sh
Restart=on-failure
RestartSec=5
[Install]
WantedBy=graphical.target
```
Enable it with `sudo systemctl daemon-reload` and `sudo systemctl enable --now usage-kiosk-browser`. The `DISPLAY`, `XAUTHORITY`, user home, and display-manager readiness vary by distribution and graphical setup; a desktop-session autostart entry is preferable when systemd starts too early for the X session.
### Kiosk X session/autostart
For a dedicated graphical user, install the launcher at `/opt/usage-kiosk/start-kiosk.sh`, make it executable, and use an X session autostart entry such as `~/.config/autostart/usage-kiosk.desktop`:
```ini
[Desktop Entry]
Type=Application
Name=Usage Dashboard Kiosk
Exec=/opt/usage-kiosk/start-kiosk.sh
Terminal=false
X-GNOME-Autostart-enabled=true
```
A window-manager session may instead run `/opt/usage-kiosk/start-kiosk.sh` from its session startup. Configure automatic graphical login for the kiosk user only on a physically secured display. Boot ordering is: network, `usage-kiosk.service`, graphical session, then Chromium kiosk. Keep the dashboard service bound to the required network interfaces and use a firewall or reverse proxy when it is not on a trusted display network.
## Troubleshooting
- **Codex error:** verify `codex` is installed, `codex login` completed, and `CODEX_COMMAND` points to one executable. Run the CLI manually outside the dashboard to confirm its availability.
- **Devin error:** verify Enterprise entitlement, service-user permissions, token scope, API URL, and configured user or organization scope. A 401/403/404 on optional organization metadata only removes the max-limit fields.
- **No Devin limit:** the consumption endpoint remains valid, but no numeric limit is invented when the organization max limit is unavailable.
- **Connection error in the header:** the browser could not complete the dashboard request; existing provider rows remain on screen and retries continue on the configured interval.
- **Stale-looking values:** successful provider results are cached for `PROVIDER_CACHE_TTL`; vendor processing and reporting delays are outside this dashboard's control.
- **Container health failure:** inspect `docker compose logs dashboard`, verify `.env`, and test `http://localhost:8080/health`.
- **Kiosk failure:** confirm one of the four supported Chromium executable names is on `PATH`, then check `DASHBOARD_URL` and the graphical session environment.
## Development checks
```sh
python -m pytest -q
python -m compileall app
docker compose config
```
View File
+53
View File
@@ -0,0 +1,53 @@
from functools import lru_cache
from zoneinfo import ZoneInfo, ZoneInfoNotFoundError
from pydantic import SecretStr, field_validator
from pydantic_settings import BaseSettings, SettingsConfigDict
class Settings(BaseSettings):
model_config = SettingsConfigDict(env_file='.env', env_file_encoding='utf-8', extra='ignore', case_sensitive=False)
host: str = '0.0.0.0'
port: int = 8080
codex_enabled: bool = True
codex_command: str = 'codex'
codex_timeout: float = 8
devin_enabled: bool = True
devin_api_key: SecretStr = SecretStr('')
devin_api_url: str = 'https://api.devin.ai'
devin_org_id: str = ''
devin_user_id: str = ''
devin_timeout: float = 8
provider_cache_ttl: float = 8
refresh_interval: int = 10
timezone: str = 'Europe/Vienna'
demo_mode: bool = False
@field_validator('codex_timeout', 'devin_timeout', 'provider_cache_ttl')
@classmethod
def positive(cls, value: float) -> float:
if value <= 0:
raise ValueError('must be positive')
return value
@field_validator('refresh_interval')
@classmethod
def positive_interval(cls, value: int) -> int:
if value < 1:
raise ValueError('must be at least 1')
return value
@field_validator('timezone')
@classmethod
def valid_timezone(cls, value: str) -> str:
try:
ZoneInfo(value)
except ZoneInfoNotFoundError as exc:
raise ValueError('invalid timezone') from exc
return value
@lru_cache
def get_settings() -> Settings:
return Settings()
+94
View File
@@ -0,0 +1,94 @@
import asyncio
from contextlib import asynccontextmanager
from datetime import datetime, timezone
from pathlib import Path
from fastapi import FastAPI, Request
from fastapi.responses import HTMLResponse, JSONResponse
from fastapi.staticfiles import StaticFiles
from fastapi.templating import Jinja2Templates
from app.config import Settings, get_settings
from app.models import ProviderUsage, UsageResponse
from app.providers.base import GENERIC_ERROR, UsageProvider
from app.providers.codex import CodexProvider
from app.providers.demo import DemoProvider
from app.providers.devin import DevinProvider
BASE_DIR = Path(__file__).resolve().parent.parent
templates = Jinja2Templates(directory=str(BASE_DIR / 'templates'))
def _disabled(provider: str) -> ProviderUsage:
return ProviderUsage(provider=provider, status='disabled', source='Configuration')
def create_app(settings: Settings | None = None,
providers: tuple[UsageProvider, UsageProvider] | None = None) -> FastAPI:
config = settings or get_settings()
selected = providers or (
DemoProvider('codex', config) if config.demo_mode else CodexProvider(config),
DemoProvider('devin', config) if config.demo_mode else DevinProvider(config),
)
codex, devin = selected
@asynccontextmanager
async def lifespan(_: FastAPI):
yield
closed: set[int] = set()
for provider in (codex, devin):
if id(provider) not in closed:
closed.add(id(provider))
await provider.aclose()
application = FastAPI(title='AI Usage Dashboard', lifespan=lifespan)
application.mount('/static', StaticFiles(directory=str(BASE_DIR / 'static')), name='static')
@application.get('/', response_class=HTMLResponse)
async def index(request: Request):
response = templates.TemplateResponse(request=request, name='index.html', context={'refresh_interval': config.refresh_interval})
response.headers['Cache-Control'] = 'no-store'
return response
@application.get('/health')
async def health():
return {'status': 'ok'}
async def one(provider: UsageProvider, enabled: bool, timeout: float, name: str) -> ProviderUsage:
if not enabled:
return _disabled(name)
try:
return await asyncio.wait_for(provider.get_usage(), timeout=timeout + 0.25)
except Exception:
return ProviderUsage(provider=name, status='error', limits=[], error=GENERIC_ERROR,
last_successful_update=provider.last_successful_update, source=provider.source)
@application.get('/api/usage', response_model=UsageResponse)
async def usage():
codex_usage, devin_usage = await asyncio.gather(
one(codex, config.codex_enabled, config.codex_timeout, 'codex'),
one(devin, config.devin_enabled, config.devin_timeout, 'devin'))
response = UsageResponse(codex=codex_usage, devin=devin_usage,
server_time=datetime.now(timezone.utc), demo_mode=config.demo_mode,
refresh_interval=config.refresh_interval)
result = response.model_dump(mode='json')
return_response = JSONResponse(content=result)
return_response.headers['Cache-Control'] = 'no-store'
return return_response
return application
app = create_app()
def run() -> None:
import uvicorn
settings = get_settings()
uvicorn.run(app, host=settings.host, port=settings.port)
if __name__ == '__main__':
run()
+35
View File
@@ -0,0 +1,35 @@
from datetime import datetime
from typing import Literal
from pydantic import BaseModel, Field
class UsageLimit(BaseModel):
id: str
name: str
used_percent: float | None = Field(default=None, ge=0, le=100)
remaining_percent: float | None = Field(default=None, ge=0, le=100)
used: float | None = Field(default=None, ge=0)
remaining: float | None = Field(default=None, ge=0)
limit: float | None = Field(default=None, ge=0)
unit: str | None = None
reset_at: datetime | None = None
window: str | None = None
updated_at: datetime
class ProviderUsage(BaseModel):
provider: Literal['codex', 'devin']
status: Literal['ok', 'error', 'disabled']
limits: list[UsageLimit] = Field(default_factory=list)
error: str | None = None
last_successful_update: datetime | None = None
source: str | None = None
class UsageResponse(BaseModel):
codex: ProviderUsage
devin: ProviderUsage
server_time: datetime
demo_mode: bool
refresh_interval: int
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()
+13
View File
@@ -0,0 +1,13 @@
services:
dashboard:
build: .
env_file:
- .env
ports:
- "8080:8080"
restart: unless-stopped
read_only: true
tmpfs:
- /tmp
security_opt:
- no-new-privileges:true
+7
View File
@@ -0,0 +1,7 @@
fastapi==0.115.6
uvicorn[standard]==0.34.0
jinja2==3.1.5
pydantic-settings==2.7.1
httpx==0.28.1
pytest==8.3.4
pytest-asyncio==0.25.2
+16
View File
@@ -0,0 +1,16 @@
#!/usr/bin/env sh
set -eu
DASHBOARD_URL="${DASHBOARD_URL:-http://localhost:8080}"
BROWSER=''
for candidate in chromium chromium-browser google-chrome google-chrome-stable; do
if command -v "$candidate" >/dev/null 2>&1; then
BROWSER="$candidate"
break
fi
done
if [ -z "$BROWSER" ]; then
printf '%s\n' 'No supported Chromium browser found (tried chromium, chromium-browser, google-chrome, google-chrome-stable).' >&2
exit 1
fi
exec "$BROWSER" --kiosk --noerrdialogs --disable-infobars --disable-session-crashed-bubble "$DASHBOARD_URL"
+90
View File
@@ -0,0 +1,90 @@
(() => {
const state = { last: { codex: null, devin: null }, refresh: 10, countdowns: new Map() };
const byId = (id) => document.getElementById(id);
const text = (node, value) => { node.textContent = value; return node; };
const fmt = (value) => Number.isInteger(value) ? String(value) : Number(value).toFixed(1).replace(/\.0$/, '');
const clockValue = (date) => date.toLocaleTimeString([], { hour12: false });
const remainingClass = (value) => value < 20 ? 'red' : value <= 50 ? 'amber' : '';
const amount = (limit) => {
if (limit.used != null && limit.limit != null) return `${fmt(limit.used)} / ${fmt(limit.limit)} ${limit.unit || ''}`.trim();
if (limit.remaining != null) return `${fmt(limit.remaining)} ${limit.unit || ''}`.trim();
return '';
};
const countdown = (date) => {
const delta = new Date(date).getTime() - Date.now();
if (!Number.isFinite(delta) || delta <= 0) return 'Reset due';
let seconds = Math.floor(delta / 1000);
const days = Math.floor(seconds / 86400); seconds %= 86400;
const hours = Math.floor(seconds / 3600); seconds %= 3600;
const minutes = Math.floor(seconds / 60); seconds %= 60;
if (days) return `Reset in ${days}d ${String(hours).padStart(2, '0')}h ${String(minutes).padStart(2, '0')}m`;
return `Reset in ${String(hours).padStart(2, '0')}h ${String(minutes).padStart(2, '0')}m ${String(seconds).padStart(2, '0')}s`;
};
const updateClock = () => text(byId('clock'), clockValue(new Date()));
const updateCountdowns = () => state.countdowns.forEach((date, node) => text(node, countdown(date)));
const renderLimit = (limit, prior) => {
const item = document.createElement('section'); item.className = 'limit';
const top = document.createElement('div'); top.className = 'limit-top';
const name = document.createElement('span'); name.className = 'limit-name'; text(name, limit.name || 'Usage limit'); top.append(name);
if (limit.remaining_percent != null) {
const remaining = document.createElement('strong'); remaining.className = `remaining ${remainingClass(limit.remaining_percent)}`; text(remaining, `${fmt(limit.remaining_percent)}%`); top.append(remaining);
}
item.append(top);
if (limit.remaining_percent != null) {
const target = Math.max(0, Math.min(100, limit.remaining_percent));
const bar = document.createElement('div'); bar.className = 'bar'; bar.setAttribute('role', 'progressbar'); bar.setAttribute('aria-valuemin', '0'); bar.setAttribute('aria-valuemax', '100'); bar.setAttribute('aria-valuenow', String(limit.remaining_percent));
const fill = document.createElement('div'); fill.className = `bar-fill ${remainingClass(limit.remaining_percent)}`;
if (prior && Number.isFinite(prior.remaining_percent) && prior.remaining_percent !== limit.remaining_percent) {
fill.style.width = `${Math.max(0, Math.min(100, prior.remaining_percent))}%`;
requestAnimationFrame(() => { fill.style.width = `${target}%`; });
} else {
fill.style.width = `${target}%`;
}
bar.append(fill); item.append(bar);
}
const meta = document.createElement('div'); meta.className = 'meta';
if (limit.used_percent != null) { const used = document.createElement('span'); text(used, `${fmt(limit.used_percent)}% used`); meta.append(used); }
const value = amount(limit); if (value) { const numeric = document.createElement('span'); numeric.className = 'amount'; text(numeric, value); meta.append(numeric); }
if (meta.childNodes.length) item.append(meta);
if (limit.reset_at) { const reset = document.createElement('div'); reset.className = 'reset'; text(reset, countdown(limit.reset_at)); state.countdowns.set(reset, limit.reset_at); item.append(reset); }
return item;
};
const clearContent = (content) => { while (content.firstChild) content.removeChild(content.firstChild); };
const renderProvider = (name, provider) => {
const content = byId(`${name}-content`); const status = byId(`${name}-status`);
status.className = `status-pill ${provider.status}`; text(status, provider.status === 'ok' ? 'LIVE' : provider.status === 'disabled' ? 'DISABLED' : 'ERROR');
if (provider.status === 'ok') {
const prior = state.last[name];
state.last[name] = provider;
state.countdowns.forEach((_, node) => { if (content.contains(node)) state.countdowns.delete(node); });
clearContent(content);
provider.limits.forEach((limit) => {
const priorLimit = prior && prior.limits.find((entry) => entry.id === limit.id);
content.append(renderLimit(limit, priorLimit));
});
return;
}
if (provider.status === 'error' && state.last[name]) {
if (!content.querySelector('.overlay')) {
const overlay = document.createElement('div'); overlay.className = 'overlay'; const strong = document.createElement('strong'); text(strong, 'Data currently unavailable'); overlay.append(strong);
const small = document.createElement('small'); const lastUpdate = provider.last_successful_update || state.last[name].last_successful_update; text(small, lastUpdate ? `Last successful update: ${clockValue(new Date(lastUpdate))}` : 'No successful update yet'); overlay.append(small); content.append(overlay);
}
return;
}
state.countdowns.forEach((_, node) => { if (content.contains(node)) state.countdowns.delete(node); });
clearContent(content);
const notice = document.createElement('div'); notice.className = 'notice'; const strong = document.createElement('strong'); text(strong, provider.status === 'disabled' ? 'Provider disabled' : 'Data currently unavailable'); notice.append(strong); const detail = document.createElement('span'); text(detail, provider.status === 'disabled' ? 'Enable this provider in configuration to view usage.' : 'Waiting for a successful update.'); notice.append(detail); content.append(notice);
};
const setConnection = (online) => { text(byId('connection-state'), online ? 'LIVE' : 'Connection error'); byId('connection-dot').classList.toggle('offline', !online); };
const fetchUsage = async () => {
try {
const response = await fetch('/api/usage', { cache: 'no-store' });
if (!response.ok) throw new Error('request failed');
const data = await response.json();
renderProvider('codex', data.codex); renderProvider('devin', data.devin); state.refresh = Math.max(1, Number(data.refresh_interval) || 10);
byId('demo-badge').hidden = !data.demo_mode; setConnection(true); text(byId('last-update'), `Last update: ${clockValue(new Date(data.server_time))}`);
window.clearInterval(state.networkTimer); state.networkTimer = window.setInterval(fetchUsage, state.refresh * 1000);
} catch (_) { setConnection(false); window.clearInterval(state.networkTimer); state.networkTimer = window.setInterval(fetchUsage, state.refresh * 1000); }
};
state.networkTimer = null; updateClock(); setInterval(() => { updateClock(); updateCountdowns(); }, 1000); fetchUsage();
})();
+34
View File
@@ -0,0 +1,34 @@
:root {
color-scheme: dark;
--bg: #080c14;
--panel: #111824;
--panel-light: #172131;
--line: #26354a;
--text: #edf4ff;
--muted: #8493a8;
--green: #49e39b;
--amber: #f2bf63;
--red: #ff687e;
font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
}
* { box-sizing: border-box; }
body { margin: 0; min-height: 100vh; color: var(--text); background: radial-gradient(circle at 85% 0%, #14233b 0, var(--bg) 42%); }
.shell { max-width: 1760px; min-height: 100vh; margin: auto; padding: 42px 54px 30px; display: flex; flex-direction: column; }
.topbar { display: grid; grid-template-columns: 1fr auto auto; align-items: end; gap: 30px; padding-bottom: 34px; border-bottom: 1px solid var(--line); }
.eyebrow { color: var(--muted); font-size: 11px; font-weight: 700; letter-spacing: .18em; }
h1, h2, p { margin: 0; } h1 { font-size: clamp(32px, 4vw, 58px); letter-spacing: -.06em; line-height: .94; } h2 { margin-top: 8px; font-size: 26px; letter-spacing: .08em; }
.header-status { display: flex; align-items: center; gap: 10px; color: var(--muted); font-size: 13px; white-space: nowrap; }
#connection-state { color: var(--green); font-weight: 700; letter-spacing: .12em; font-size: 11px; } #clock { color: var(--text); font-variant-numeric: tabular-nums; margin-left: 16px; } #last-update { margin-left: 10px; }
.live-dot { width: 8px; height: 8px; border-radius: 50%; background: var(--green); box-shadow: 0 0 14px var(--green); } .live-dot.offline { background: var(--red); box-shadow: 0 0 14px var(--red); }
.demo-badge { padding: 9px 13px; border: 1px solid #7d6434; background: #392d14; color: #f3c86b; border-radius: 5px; font-size: 11px; font-weight: 800; letter-spacing: .16em; }
.cards { flex: 1; display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 24px; padding-top: 28px; }
.provider-card { position: relative; min-width: 0; padding: 27px 29px; background: linear-gradient(145deg, rgba(24,35,52,.97), rgba(13,19,30,.97)); border: 1px solid var(--line); border-radius: 12px; box-shadow: 0 20px 50px rgba(0,0,0,.22); }
.card-heading { display: flex; justify-content: space-between; align-items: start; padding-bottom: 20px; border-bottom: 1px solid var(--line); } .status-pill { color: var(--green); border: 1px solid currentColor; padding: 5px 8px; border-radius: 4px; font-size: 10px; font-weight: 800; letter-spacing: .12em; } .status-pill.error { color: var(--red); } .status-pill.disabled { color: var(--muted); }
.limits { display: grid; grid-template-columns: repeat(auto-fit, minmax(270px, 1fr)); gap: 14px; padding-top: 20px; align-content: start; } .limit { min-width: 0; padding: 17px; border: 1px solid #25344a; border-radius: 8px; background: rgba(11,17,27,.62); }
.limit-top { display: flex; justify-content: space-between; gap: 12px; align-items: baseline; } .limit-name { color: var(--muted); font-size: 12px; font-weight: 700; letter-spacing: .06em; text-transform: uppercase; } .remaining { color: var(--green); font-size: 30px; font-weight: 750; letter-spacing: -.05em; white-space: nowrap; } .remaining.amber { color: var(--amber); } .remaining.red { color: var(--red); }
.bar { height: 7px; margin: 15px 0 11px; overflow: hidden; border-radius: 6px; background: #263447; } .bar-fill { height: 100%; width: 0; border-radius: inherit; background: var(--green); transition: width .55s ease; } .bar-fill.amber { background: var(--amber); } .bar-fill.red { background: var(--red); }
.meta { display: flex; justify-content: space-between; gap: 8px; color: var(--muted); font-size: 12px; } .amount { color: var(--text); } .reset { margin-top: 14px; color: #b5c1d1; font-size: 12px; font-variant-numeric: tabular-nums; }
.notice { padding: 22px 14px; color: var(--muted); font-size: 14px; } .notice strong { display: block; color: var(--text); margin-bottom: 6px; } .overlay { margin: 18px 0 0; padding: 13px 15px; color: #ffc4ca; border-left: 2px solid var(--red); background: rgba(130,34,52,.15); font-size: 13px; } .overlay small { display: block; margin-top: 5px; color: var(--muted); }
@media (max-width: 900px) { .shell { padding: 28px 20px; } .topbar { grid-template-columns: 1fr auto; gap: 18px; } .header-status { grid-column: 1 / -1; grid-row: 2; } .demo-badge { grid-column: 2; grid-row: 1; } .cards { grid-template-columns: 1fr; } }
@media (max-width: 520px) { .shell { padding: 20px 14px; } .provider-card { padding: 21px 17px; } .limits { grid-template-columns: 1fr; } #last-update { display: none; } }
@media (prefers-reduced-motion: reduce) { *, *::before, *::after { scroll-behavior: auto !important; transition-duration: .01ms !important; animation-duration: .01ms !important; } }
+30
View File
@@ -0,0 +1,30 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="color-scheme" content="dark">
<title>AI Usage Dashboard</title>
<link rel="stylesheet" href="/static/style.css">
</head>
<body>
<main class="shell">
<header class="topbar">
<div class="brand"><span class="eyebrow">MONITORING CONSOLE</span><h1>AI USAGE</h1></div>
<div class="header-status"><span id="connection-dot" class="live-dot" aria-hidden="true"></span><span id="connection-state">LIVE</span><span id="clock">--:--:--</span><span id="last-update">Last update: --:--:--</span></div>
<div id="demo-badge" class="demo-badge" hidden>DEMO DATA</div>
</header>
<section class="cards" aria-label="Provider usage">
<article id="codex-card" class="provider-card" aria-labelledby="codex-heading">
<div class="card-heading"><div><span class="eyebrow">PROVIDER</span><h2 id="codex-heading">CODEX</h2></div><span id="codex-status" class="status-pill">CONNECTING</span></div>
<div id="codex-content" class="limits" aria-live="polite"></div>
</article>
<article id="devin-card" class="provider-card" aria-labelledby="devin-heading">
<div class="card-heading"><div><span class="eyebrow">PROVIDER</span><h2 id="devin-heading">DEVIN</h2></div><span id="devin-status" class="status-pill">CONNECTING</span></div>
<div id="devin-content" class="limits" aria-live="polite"></div>
</article>
</section>
</main>
<script src="/static/app.js"></script>
</body>
</html>
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