diff --git a/.env.example b/.env.example index 299b3c7..f27e180 100644 --- a/.env.example +++ b/.env.example @@ -6,8 +6,10 @@ CODEX_TIMEOUT=8 DEVIN_ENABLED=true DEVIN_API_KEY= DEVIN_API_URL=https://api.devin.ai +DEVIN_MODE=enterprise DEVIN_ORG_ID= DEVIN_USER_ID= +DEVIN_SESSION_WINDOW_DAYS=30 DEVIN_TIMEOUT=8 PROVIDER_CACHE_TTL=8 REFRESH_INTERVAL=10 diff --git a/README.md b/README.md index 9580a93..3d7c927 100644 --- a/README.md +++ b/README.md @@ -60,8 +60,10 @@ Copy `.env.example` to `.env`. Unknown environment variables are ignored and nam | `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_MODE` | `enterprise` | `enterprise` billing consumption or `organization_sessions` rolling session usage | +| `DEVIN_ORG_ID` | empty | Organization scope, required by organization sessions mode | +| `DEVIN_USER_ID` | empty | Enterprise user scope; optional user filter in organization sessions mode | +| `DEVIN_SESSION_WINDOW_DAYS` | `30` | Rolling organization-session window from 1 to 365 days | | `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 | @@ -82,9 +84,22 @@ The app-server interface is official, but is documented as a development/debug i ### 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. +The provider has two official Devin modes. `enterprise` uses the API v3 Enterprise consumption endpoints: it selects the active billing cycle from `/v3/enterprise/consumption/cycles`, then requests daily consumption using user scope, organization scope, or the account endpoint in that precedence order. It can show true billing-cycle usage and limits when available. Enterprise consumption requires a service user with `ViewAccountConsumption`; reading the organization max limit may require extra organization-read permissions. -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. +`organization_sessions` uses `GET /v3/organizations/{org_id}/sessions` with a `cog_` service-user key, `DEVIN_ORG_ID`, and the `ViewOrgSessions` permission. It is suitable for non-Enterprise/self-serve organizations when that endpoint is permitted. The mode sums each returned session's cumulative `acus_consumed` for sessions whose `created_at` falls inside the rolling N-day query. It is not billing-cycle usage, does not apportion a long session's ACUs by date, and cannot show self-serve quota remaining, credit balance, percentage, or reset because Devin does not expose those values through this API. The endpoint's `created_after` filter defines which sessions are included. + +Example setup: + +```env +DEVIN_ENABLED=true +DEVIN_MODE=organization_sessions +DEVIN_API_KEY=cog_your_service_user_key +DEVIN_ORG_ID=org-your-id +DEVIN_USER_ID= +DEVIN_SESSION_WINDOW_DAYS=30 +``` + +A successful `200` response from `/v3/organizations/{org_id}/sessions?first=1` confirms that the organization-session endpoint is permitted for the configured service user. 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 @@ -190,8 +205,9 @@ A window-manager session may instead run `/opt/usage-kiosk/start-kiosk.sh` from ## 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. +- **Devin Enterprise error:** verify Enterprise entitlement, service-user `ViewAccountConsumption` permission, token scope, API URL, and configured user or organization scope. A 401/403/404 on optional organization metadata only removes the max-limit fields. +- **Devin organization sessions 403:** verify that `DEVIN_MODE=organization_sessions`, `DEVIN_ORG_ID`, the `cog_` service-user key, and `ViewOrgSessions` are correct. Confirm access with `/v3/organizations/{org_id}/sessions?first=1`; this mode does not use Enterprise consumption permissions. +- **No Devin limit:** Enterprise consumption remains valid, but no numeric limit is invented when the organization max limit is unavailable. Organization sessions mode intentionally has no quota, percentage, credit, or reset fields. - **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`. diff --git a/app/config.py b/app/config.py index 15d1b7f..78509cf 100644 --- a/app/config.py +++ b/app/config.py @@ -1,4 +1,5 @@ from functools import lru_cache +from typing import Literal from zoneinfo import ZoneInfo, ZoneInfoNotFoundError from pydantic import SecretStr, field_validator @@ -16,8 +17,10 @@ class Settings(BaseSettings): devin_enabled: bool = True devin_api_key: SecretStr = SecretStr('') devin_api_url: str = 'https://api.devin.ai' + devin_mode: Literal['enterprise', 'organization_sessions'] = 'enterprise' devin_org_id: str = '' devin_user_id: str = '' + devin_session_window_days: int = 30 devin_timeout: float = 8 provider_cache_ttl: float = 8 refresh_interval: int = 10 @@ -38,6 +41,13 @@ class Settings(BaseSettings): raise ValueError('must be at least 1') return value + @field_validator('devin_session_window_days') + @classmethod + def valid_session_window(cls, value: int) -> int: + if not 1 <= value <= 365: + raise ValueError('must be between 1 and 365') + return value + @field_validator('timezone') @classmethod def valid_timezone(cls, value: str) -> str: diff --git a/app/providers/devin.py b/app/providers/devin.py index 836a448..3024296 100644 --- a/app/providers/devin.py +++ b/app/providers/devin.py @@ -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() diff --git a/static/app.js b/static/app.js index e9e5c6c..6b3fe61 100644 --- a/static/app.js +++ b/static/app.js @@ -7,6 +7,7 @@ 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.used != null) return `${fmt(limit.used)} ${limit.unit || ''}`.trim(); if (limit.remaining != null) return `${fmt(limit.remaining)} ${limit.unit || ''}`.trim(); return ''; }; @@ -43,6 +44,7 @@ bar.append(fill); item.append(bar); } const meta = document.createElement('div'); meta.className = 'meta'; + if (limit.remaining_percent == null && limit.used_percent == null) meta.classList.add('standalone'); 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); diff --git a/static/style.css b/static/style.css index 828ea9f..d3e6112 100644 --- a/static/style.css +++ b/static/style.css @@ -27,7 +27,7 @@ h1, h2, p { margin: 0; } h1 { font-size: clamp(32px, 4vw, 58px); letter-spacing: .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; } +.meta { display: flex; justify-content: space-between; gap: 8px; color: var(--muted); font-size: 12px; } .amount { color: var(--text); } .meta.standalone { display: block; margin-top: 18px; } .meta.standalone .amount { display: block; color: var(--text); font-size: clamp(24px, 2.2vw, 34px); font-weight: 750; letter-spacing: -.04em; } .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; } } diff --git a/tests/test_devin.py b/tests/test_devin.py index 419f444..31d4fdc 100644 --- a/tests/test_devin.py +++ b/tests/test_devin.py @@ -123,3 +123,95 @@ async def test_optional_org_forbidden_keeps_consumption(): assert result.limits[0].used == 12 assert result.limits[0].limit is None await client.aclose() + + +@pytest.mark.asyncio +async def test_organization_sessions_sums_pages_and_user_filter(monkeypatch): + now = 1_800_000_000 + days = 30 + monkeypatch.setattr('app.providers.devin.time.time', lambda: now) + requests = [] + + async def handler(request): + requests.append(request) + assert request.url.raw_path.split(b'?', 1)[0] == b'/v3/organizations/org%2Fid/sessions' + assert request.url.params.get('first') == '200' + assert int(request.url.params['created_before']) == now + assert int(request.url.params['created_after']) == now - days * 86400 + assert request.url.params.get_list('user_ids') == ['user-123'] + if request.url.params.get('after') == 'next': + return httpx.Response(200, json={'items': [ + {'session_id': 'session-2', 'created_at': now - 20, 'acus_consumed': 7.6}, + ]}) + return httpx.Response(200, json={'items': [ + {'session_id': 'session-1', 'created_at': now - 10, 'acus_consumed': 12.4}, + ], 'has_next_page': True, 'end_cursor': 'next'}) + + client = make_client(handler) + provider = DevinProvider(Settings(devin_mode='organization_sessions', devin_org_id='org/id', + devin_user_id=' user-123 ', devin_session_window_days=days), client) + result = await provider.get_usage() + assert result.status == 'ok' + assert result.source == 'Devin Organization Sessions API' + assert len(requests) == 2 + assert requests[1].url.params.get('after') == 'next' + acus, count = result.limits + assert acus.id == 'organization-session-acus' + assert acus.name == '30-Day Session Usage' + assert acus.used == pytest.approx(20) + assert acus.unit == 'ACUs' + assert acus.window == 'sessions created in the last 30 days' + assert all(getattr(acus, field) is None for field in ('used_percent', 'remaining_percent', 'remaining', 'limit', 'reset_at')) + assert count.id == 'organization-session-count' + assert count.used == 2 + assert count.unit == 'sessions' + assert count.window == 'created in the last 30 days' + assert all(getattr(count, field) is None for field in ('used_percent', 'remaining_percent', 'remaining', 'limit', 'reset_at')) + await client.aclose() + + +@pytest.mark.asyncio +async def test_organization_sessions_requires_org_without_request(): + requests = [] + + async def handler(request): + requests.append(request) + return httpx.Response(200, json={'items': []}) + + client = make_client(handler) + provider = DevinProvider(Settings(devin_mode='organization_sessions'), client) + result = await provider.get_usage() + assert result.status == 'error' + assert result.error == 'Unable to retrieve usage information' + assert requests == [] + await client.aclose() + + +@pytest.mark.asyncio +async def test_organization_sessions_forbidden_is_generic(): + async def handler(request): + return httpx.Response(403, text='secret organization response') + + client = make_client(handler) + provider = DevinProvider(Settings(devin_mode='organization_sessions', devin_org_id='org'), client) + result = await provider.get_usage() + assert result.status == 'error' + assert result.error == 'Unable to retrieve usage information' + assert 'secret organization response' not in result.model_dump_json() + await client.aclose() + + +@pytest.mark.asyncio +@pytest.mark.parametrize('bad_acus', [-1, 'not-a-number']) +async def test_organization_sessions_rejects_bad_acus(bad_acus): + async def handler(request): + return httpx.Response(200, json={'items': [ + {'session_id': 'session-1', 'created_at': 1_800_000_000, 'acus_consumed': bad_acus}, + ]}) + + client = make_client(handler) + provider = DevinProvider(Settings(devin_mode='organization_sessions', devin_org_id='org'), client) + result = await provider.get_usage() + assert result.status == 'error' + assert result.error == 'Unable to retrieve usage information' + await client.aclose() diff --git a/tests/test_static.py b/tests/test_static.py index c23290a..26395b7 100644 --- a/tests/test_static.py +++ b/tests/test_static.py @@ -13,4 +13,8 @@ def test_frontend_assets_exist_and_use_api(): assert "fetch('/api/usage'" in script assert 'entry.id === limit.id' in script assert 'requestAnimationFrame' in script + assert "if (limit.used != null) return `${fmt(limit.used)} ${limit.unit || ''}`.trim();" in script + assert "meta.classList.add('standalone')" in script + assert '.meta.standalone .amount' in css + assert 'font-size: clamp(24px, 2.2vw, 34px)' in css assert 'grid-template-columns' in css