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:
+68
-1
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user