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,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()
|
||||
Reference in New Issue
Block a user