Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
25e5064
feat: add daily conversation quota schema foundation
neubig Aug 19, 2026
a5e676f
fix: use single quotes in storage test to satisfy ruff
neubig Aug 19, 2026
b199912
feat: add read-only quota usage page with reset countdown
neubig Aug 19, 2026
6caa8e1
fix: handle nullable daily_limit in TypeScript for quota settings
neubig Aug 19, 2026
757dfb1
feat: add org-level daily conversation quota exemptions
neubig Aug 20, 2026
1da9369
Merge remote-tracking branch 'origin/main' into feat/daily-conversati…
hieptl Aug 20, 2026
d06fa3a
fix: renumber quota migration to 150 and drop the user-limit backfill
hieptl Aug 20, 2026
c1271fb
Merge branch 'feat/daily-conversation-limit' into feat/quota-usage-page
hieptl Aug 20, 2026
80433f4
fix: resolve frontend lint and translation errors on quota page
hieptl Aug 20, 2026
4d98b0b
Merge branch 'feat/quota-usage-page' into feat/org-level-quota-exempt…
hieptl Aug 20, 2026
66163a3
fix: renumber org quota migration to 151
hieptl Aug 20, 2026
1347c21
fix: use single quotes in daily usage storage test
hieptl Aug 20, 2026
62607b6
Merge branch 'feat/daily-conversation-limit' into feat/quota-usage-page
hieptl Aug 20, 2026
58ac572
Merge branch 'feat/quota-usage-page' into feat/org-level-quota-exempt…
hieptl Aug 20, 2026
7b3dc53
fix: satisfy enterprise ruff on quota status files
hieptl Aug 20, 2026
7cb72e8
Merge branch 'feat/quota-usage-page' into feat/org-level-quota-exempt…
hieptl Aug 20, 2026
a2f3f20
fix: satisfy enterprise ruff on org quota files
hieptl Aug 20, 2026
7bb0228
fix: resolve merge conflicts
hieptl Aug 24, 2026
e3a975e
fix: address review findings on org-level quota exemptions
hieptl Aug 24, 2026
5639c68
fix: log org quota changes with the calling admin
hieptl Aug 24, 2026
9461a66
Merge branch 'main' into feat/org-level-quota-exemptions
hieptl Aug 24, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
"""Add org-level daily conversation limit override."""

from typing import Sequence, Union

import sqlalchemy as sa
from alembic import op

revision: str = '151'
down_revision: Union[str, None] = '150'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None


def upgrade() -> None:
bind = op.get_bind()
if bind.dialect.name != 'postgresql':
raise RuntimeError(f'Unsupported database dialect: {bind.dialect.name}')

op.add_column(
'org',
sa.Column('daily_conversation_limit', sa.Integer(), nullable=True),
)


def downgrade() -> None:
op.drop_column('org', 'daily_conversation_limit')
3 changes: 2 additions & 1 deletion enterprise/saas_server.py
Original file line number Diff line number Diff line change
Expand Up @@ -53,7 +53,7 @@
)
from server.routes.org_profiles import router as org_profiles_router # noqa: E402
from server.routes.orgs import org_router # noqa: E402
from server.routes.quota import quota_router # noqa: E402
from server.routes.quota import quota_admin_router, quota_router # noqa: E402
from server.routes.readiness import readiness_router # noqa: E402
from server.routes.service import service_router # noqa: E402
from server.routes.super_admins import super_admin_router # noqa: E402
Expand Down Expand Up @@ -211,6 +211,7 @@ def is_saas():
analytics_events_router
) # Add routes for client-initiated analytics events
base_app.include_router(quota_router) # Add routes for quota status
base_app.include_router(quota_admin_router) # Add admin routes for org-level quota


base_app.add_middleware(
Expand Down
10 changes: 9 additions & 1 deletion enterprise/server/auth/authorization.py
Original file line number Diff line number Diff line change
Expand Up @@ -107,6 +107,12 @@ class Permission(str, Enum):
# granted only to the ``superadmin`` super role.
MANAGE_SUPER_ADMINS = 'manage_super_admins'

# Instance-level quota administration: set or clear an organization's
# daily conversation limit. Like MANAGE_SUPER_ADMINS this is an explicit
# instance-admin capability -- it is NOT implied by any org-scoped role,
# so an org owner cannot lift their own org's quota.
MANAGE_ORG_QUOTA = 'manage_org_quota'


class RoleName(str, Enum):
"""Role names used in the system.
Expand Down Expand Up @@ -233,7 +239,8 @@ def super_role_name(role_name: str) -> str:
SUPER_ROLE_PERMISSIONS: dict[RoleName, frozenset[Permission]] = {
# Only superadmin is functional for now. It can create organizations,
# provision users into a selected organization without becoming an org
# member itself, and grant/revoke the super-admin role on other users.
# member itself, grant/revoke the super-admin role on other users, and
# set org-level conversation quotas.
# Additional instance-admin capabilities should be added here explicitly
# as the corresponding routes are wired to permission checks.
RoleName.OWNER: frozenset(),
Expand All @@ -242,6 +249,7 @@ def super_role_name(role_name: str) -> str:
Permission.CREATE_ORGANIZATION,
Permission.PROVISION_USER,
Permission.MANAGE_SUPER_ADMINS,
Permission.MANAGE_ORG_QUOTA,
]
),
RoleName.MEMBER: frozenset(),
Expand Down
123 changes: 113 additions & 10 deletions enterprise/server/routes/quota.py
Original file line number Diff line number Diff line change
@@ -1,14 +1,22 @@
"""Quota status API for the settings page."""
"""Quota status and org-level quota management API."""

from fastapi import APIRouter, Depends
from pydantic import BaseModel
from uuid import UUID

from fastapi import APIRouter, Depends, HTTPException, status
from pydantic import BaseModel, Field, field_validator
from server.auth.authorization import Permission, require_permission
from server.auth.org_context import EFFECTIVE_ORG_ID, REJECT_X_ORG_ID_PATH_MISMATCH
from server.services.daily_conversation_quota_service import (
EXEMPT_LIMIT,
DailyConversationQuotaService,
)
from sqlalchemy import select
from storage.database import a_session_maker
from storage.org import Org

from openhands.app_server.user_auth import get_user_id
from openhands.app_server.utils.dependencies import get_dependencies
from openhands.app_server.utils.logger import openhands_logger as logger

quota_router = APIRouter(
prefix='/api/quota', tags=['Quota'], dependencies=get_dependencies()
Expand All @@ -23,14 +31,109 @@ class QuotaStatusResponse(BaseModel):


@quota_router.get('/status', response_model=QuotaStatusResponse)
async def get_quota_status(user_id: str = Depends(get_user_id)) -> QuotaStatusResponse:
"""Return the authenticated user's daily conversation quota status."""
async def get_quota_status(
user_id: str = Depends(get_user_id),
effective_org_id: UUID = EFFECTIVE_ORG_ID,
) -> QuotaStatusResponse:
"""Return the authenticated user's daily conversation quota status.

Scoped to the request's effective org (``X-Org-Id`` / API-key binding),
so a user in several orgs sees the quota of the org they are actually
working in.
"""
async with a_session_maker() as session:
service = DailyConversationQuotaService(session)
status = await service.get_status(user_id)
quota = await service.get_status(user_id, effective_org_id)
return QuotaStatusResponse(
daily_limit=status.daily_limit,
used_today=status.used_today,
remaining=status.remaining,
reset_at=status.reset_at,
daily_limit=quota.daily_limit,
used_today=quota.used_today,
remaining=quota.remaining,
reset_at=quota.reset_at,
)


# --- Org-level quota management (admin) ---

quota_admin_router = APIRouter(
prefix='/api/admin/quota',
tags=['Admin'],
dependencies=[*get_dependencies(), REJECT_X_ORG_ID_PATH_MISMATCH],
)


class OrgQuotaUpdateRequest(BaseModel):
daily_conversation_limit: int | None = Field(
description=(
'NULL to inherit the deployment default, -1 to exempt the org '
'entirely, or a positive integer for an org-specific limit.'
),
)

@field_validator('daily_conversation_limit')
@classmethod
def _reject_meaningless_limits(cls, value: int | None) -> int | None:
"""Allow only NULL, the exemption sentinel, and positive limits.

0 and values below -1 are rejected rather than stored: they are not
meaningful quotas, but they resolve to a limit the org can never
satisfy, silently blocking every member with no error anywhere. A
mistyped '-11' for '-1' would otherwise do the exact opposite of the
intended exemption.
"""
if value is None or value == EXEMPT_LIMIT or value > 0:
return value
raise ValueError(
f'daily_conversation_limit must be null (inherit), {EXEMPT_LIMIT} '
f'(exempt), or a positive integer; got {value}'
)


class OrgQuotaResponse(BaseModel):
org_id: str
org_name: str
daily_conversation_limit: int | None


@quota_admin_router.put('/orgs/{org_id}/quota', response_model=OrgQuotaResponse)
async def set_org_quota(
org_id: UUID,
body: OrgQuotaUpdateRequest,
caller_user_id: str = Depends(require_permission(Permission.MANAGE_ORG_QUOTA)),
) -> OrgQuotaResponse:
"""Set or clear an org-level daily conversation limit override.

Requires the instance-level ``MANAGE_ORG_QUOTA`` permission, which is
granted only to the superadmin super role. Going through
``require_permission`` (rather than checking the super role inline) also
enforces the API-key organization binding, so a key bound to one org
cannot edit another org's quota.

Set to -1 to exempt the org entirely (unlimited).
Set to NULL to inherit the deployment default.
Set to a positive integer for an org-specific limit.
"""
async with a_session_maker() as session:
org = await session.scalar(select(Org).where(Org.id == org_id))
if org is None:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail='Organization not found',
)
org.daily_conversation_limit = body.daily_conversation_limit
await session.commit()

logger.info(
'org_quota:set',
extra={
'caller_user_id': caller_user_id,
'org_id': str(org.id),
'daily_conversation_limit': org.daily_conversation_limit,
'exempt': org.daily_conversation_limit == EXEMPT_LIMIT,
},
)

return OrgQuotaResponse(
org_id=str(org.id),
org_name=org.name,
daily_conversation_limit=org.daily_conversation_limit,
)
48 changes: 42 additions & 6 deletions enterprise/server/services/daily_conversation_quota_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,10 +10,17 @@
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from storage.daily_conversation_usage import DailyConversationUsage
from storage.org import Org
from storage.user import User

DEFAULT_ENV_VAR = 'OH_DAILY_CONVERSATION_LIMIT'

# Sentinel stored in ``user.daily_conversation_limit`` /
# ``org.daily_conversation_limit`` meaning "exempt -- no limit at all".
# NULL cannot carry that meaning because NULL already means "inherit from
# the next level down".
EXEMPT_LIMIT = -1


def configured_daily_limit() -> int | None:
"""Read the deployment default; unset and blank mean unlimited."""
Expand All @@ -38,8 +45,8 @@ class DailyConversationQuotaService:
def __init__(self, db_session: AsyncSession) -> None:
self.db_session = db_session

async def get_status(self, user_id: str) -> QuotaStatus:
limit = await self.get_limit(user_id)
async def get_status(self, user_id: str, org_id: UUID) -> QuotaStatus:
limit = await self.get_limit(user_id, org_id)
used = await self._used(user_id, datetime.now(UTC).date())
remaining = None if limit is None else max(limit - used, 0)
reset_at = self._next_reset_iso()
Expand All @@ -50,13 +57,42 @@ async def get_status(self, user_id: str) -> QuotaStatus:
reset_at=reset_at,
)

async def get_limit(self, user_id: str) -> int | None:
async def get_limit(self, user_id: str, org_id: UUID) -> int | None:
"""Resolve the effective daily conversation limit, or None for unlimited.

Precedence (first non-NULL level wins):

1. User-level override (``user.daily_conversation_limit``)
2. Org-level override (``org.daily_conversation_limit``)
3. Deployment default (``OH_DAILY_CONVERSATION_LIMIT`` env var)

NULL at a level means "inherit from the next level down"; it never
means exempt. To exempt (e.g. a paying SaaS org) store
``EXEMPT_LIMIT`` (-1), which resolves to None at either level.

``org_id`` must be the request's *effective* org -- resolved via
``EFFECTIVE_ORG_ID`` so the API-key binding and ``X-Org-Id`` header
are honored. It is deliberately not derived from
``user.current_org_id`` here: that is only the user's last-selected
org and would apply the wrong org's quota (or the wrong org's
exemption) whenever the request is scoped to a different one.
"""
user = await self.db_session.scalar(
select(User).where(User.id == UUID(user_id))
)
if user is None or user.daily_conversation_limit is None:
return configured_daily_limit()
return user.daily_conversation_limit
if user is not None and user.daily_conversation_limit is not None:
return self._resolve_sentinel(user.daily_conversation_limit)

org = await self.db_session.scalar(select(Org).where(Org.id == org_id))
if org is not None and org.daily_conversation_limit is not None:
return self._resolve_sentinel(org.daily_conversation_limit)

return configured_daily_limit()

@staticmethod
def _resolve_sentinel(limit: int) -> int | None:
"""Translate the stored exemption sentinel into "unlimited"."""
return None if limit == EXEMPT_LIMIT else limit

@staticmethod
def _next_reset_iso() -> str:
Expand Down
4 changes: 4 additions & 0 deletions enterprise/storage/org.py
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,10 @@ class Org(Base):
# Set by completed billing sessions or when positive org credits are detected.
byor_export_enabled: Mapped[bool] = mapped_column(nullable=False, default=False)
sandbox_grouping_strategy: Mapped[str | None] = mapped_column(String, nullable=True)
# NULL inherits the deployment-wide daily conversation limit.
# Set to -1 to exempt this org from daily conversation limits entirely
# (for paying SaaS customers). Any other integer is the org-specific limit.
daily_conversation_limit: Mapped[int | None] = mapped_column(nullable=True)
# Encrypted column for LLM profiles (contains API keys)
llm_profiles: Mapped[dict[str, Any] | None] = mapped_column(
EncryptedJSON, nullable=True
Expand Down
Loading
Loading