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,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
@@ -0,0 +1,13 @@
|
||||
.env
|
||||
.env.*
|
||||
!.env.example
|
||||
.venv/
|
||||
venv/
|
||||
__pycache__/
|
||||
*.py[cod]
|
||||
.pytest_cache/
|
||||
.mypy_cache/
|
||||
.coverage
|
||||
htmlcov/
|
||||
.idea/
|
||||
.vscode/
|
||||
+15
@@ -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"]
|
||||
@@ -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
|
||||
```
|
||||
@@ -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
@@ -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()
|
||||
@@ -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
|
||||
@@ -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()
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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"
|
||||
@@ -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();
|
||||
})();
|
||||
@@ -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; } }
|
||||
@@ -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>
|
||||
@@ -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