feat: add organization sessions mode for Devin self-serve usage tracking

Add `DEVIN_MODE` configuration supporting `enterprise` (default) and `organization_sessions` modes. Organization sessions mode uses `/v3/organizations/{org_id}/sessions` API with `ViewOrgSessions` permission, suitable for non-Enterprise organizations. Sums session ACUs consumed within configurable rolling window (1-365 days via `DEVIN_SESSION_WINDOW_DAYS`). Mode does not show quota, percentage, credit, or reset fields as these are unav
This commit is contained in:
2026-09-18 12:04:13 +02:00
parent 58eab24258
commit 792f85fc8b
8 changed files with 201 additions and 8 deletions
+68 -1
View File
@@ -37,6 +37,20 @@ class OrgResponse(BaseModel):
max_cycle_acu_limit: Any = None
class SessionSummary(BaseModel):
model_config = ConfigDict(extra='ignore')
session_id: StrictStr
created_at: StrictInt
acus_consumed: Any
class SessionsResponse(BaseModel):
model_config = ConfigDict(extra='ignore')
items: list[SessionSummary]
has_next_page: StrictBool = False
end_cursor: StrictStr | None = None
def _acu(value: Any) -> float:
if isinstance(value, bool) or not isinstance(value, (int, float)):
raise ValueError
@@ -48,13 +62,19 @@ def _acu(value: Any) -> float:
class DevinProvider(UsageProvider):
def __init__(self, settings: Settings, client: httpx.AsyncClient | None = None):
super().__init__('devin', settings.provider_cache_ttl, 'Devin API v3')
source = 'Devin Organization Sessions API' if settings.devin_mode == 'organization_sessions' else 'Devin API v3'
super().__init__('devin', settings.provider_cache_ttl, source)
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:
if self.settings.devin_mode == 'organization_sessions':
return await self._fetch_organization_sessions()
return await self._fetch_enterprise_usage()
async def _fetch_enterprise_usage(self) -> ProviderUsage:
now = int(time.time())
params = {'first': 100}
seen_cursors: set[str] = set()
@@ -124,6 +144,53 @@ class DevinProvider(UsageProvider):
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 _fetch_organization_sessions(self) -> ProviderUsage:
org_id = self.settings.devin_org_id.strip()
if not org_id:
raise ValueError
user_id = self.settings.devin_user_id.strip()
now = int(time.time())
days = self.settings.devin_session_window_days
params: dict[str, Any] = {
'first': 200,
'created_after': now - days * 86400,
'created_before': now,
}
if user_id:
params['user_ids'] = [user_id]
seen_cursors: set[str] = set()
sessions: list[SessionSummary] = []
for page in range(100):
response = await self._client.get(
f'/v3/organizations/{quote(org_id, safe="")}/sessions', params=params)
response.raise_for_status()
try:
sessions_page = SessionsResponse.model_validate(response.json())
except (ValidationError, ValueError, TypeError):
raise ValueError
sessions.extend(sessions_page.items)
if not sessions_page.has_next_page:
break
cursor = sessions_page.end_cursor
if not cursor or cursor in seen_cursors:
raise ValueError
seen_cursors.add(cursor)
params = {'first': 200, 'after': cursor, 'created_after': now - days * 86400,
'created_before': now}
if user_id:
params['user_ids'] = [user_id]
else:
raise ValueError
total_acus = sum(_acu(session.acus_consumed) for session in sessions)
updated = utc_now()
window = f'created in the last {days} days'
return ProviderUsage(provider='devin', status='ok', limits=[
UsageLimit(id='organization-session-acus', name=f'{days}-Day Session Usage', used=total_acus,
unit='ACUs', window=f'sessions {window}', updated_at=updated),
UsageLimit(id='organization-session-count', name='Sessions Included', used=float(len(sessions)),
unit='sessions', window=window, updated_at=updated),
], last_successful_update=updated, source=self.source)
async def aclose(self) -> None:
if self._owns_client:
await self._client.aclose()