Skip to content

feat: add org-level daily conversation quota exemptions - #212

Merged
hieptl merged 21 commits into
mainfrom
feat/org-level-quota-exemptions
Aug 24, 2026
Merged

feat: add org-level daily conversation quota exemptions#212
hieptl merged 21 commits into
mainfrom
feat/org-level-quota-exemptions

Conversation

@neubig

@neubig neubig commented Aug 20, 2026

Copy link
Copy Markdown
Member

Adds org-level daily conversation quota support so that specific organizations can be exempted from limits or given custom limits, independent of the deployment-wide default.

What

  • Migration 149_org_quota: Adds nullable daily_conversation_limit column to the org table
  • Quota resolution precedence: user override → org override → env default → None (unlimited)
    • User-level daily_conversation_limit (int) — per-user override
    • Org-level daily_conversation_limit (int) — org-specific limit, or -1 to exempt the org entirely
    • OH_DAILY_CONVERSATION_LIMIT env var — deployment-wide default
    • NULL at any level means "inherit from the next level down"
  • Admin API: PUT /api/admin/quota/orgs/{org_id}/quota — superadmin-gated endpoint to set or clear org-level limits
  • 6 focused tests covering user precedence, org override, org exemption (-1), env fallback, and unset/unlimited resolution

Why

Limits must be configurable at the org level so that paying SaaS customers can be exempted while free-tier orgs remain rate-limited. This also ensures OHE (on-prem enterprise) customers are unaffected — enforcement is gated on SaaS mode, and the env var defaults to unset (unlimited).

Stacking

This PR is stacked on #199 (usage page), which is stacked on #180 (schema).

PR Scope
#180 Schema foundation: limit column + usage table
#199 Read-only usage API + settings page with countdown
This PR Org-level quota exemptions and admin API
#200 Work-email quota requests, verification, admin APIs, PostHog
#201 Conversation enforcement, structured 429, error display

This pull request was created by an AI agent (OpenHands) on behalf of the user.


Enterprise server image for this PR:

ghcr.io/openhands/enterprise-server:sha-9461a66

neubig and others added 5 commits August 19, 2026 04:23
Add migration 148 with a nullable per-user daily_conversation_limit
override column on the user table and a daily_conversation_usage table
for atomic per-user, per-UTC-day conversation accounting with a unique
(user_id, usage_date) constraint. Register the model and add focused
storage tests for the nullable override and uniqueness invariant.

No enforcement or API behavior is introduced in this foundation; that
lands in subsequent stacked PRs.

Co-authored-by: openhands <openhands@all-hands.dev>
Co-authored-by: openhands <openhands@all-hands.dev>
Add GET /api/quota/status returning the authenticated user's effective
daily limit, used count, remaining, and next UTC-midnight reset_at.
Add a SaaS-only settings page at /settings/quota showing a progress bar
and a live HH:MM:SS countdown to the next reset. Includes focused tests
for the quota status service covering unlimited, partial, exhausted,
and no-usage-today cases.

Stacked on feat/daily-conversation-limit (PR #180).

Co-authored-by: openhands <openhands@all-hands.dev>
Co-authored-by: openhands <openhands@all-hands.dev>
Add migration 149_org_quota with a nullable daily_conversation_limit
column on the org table. Update the quota service to resolve limits
with the precedence: user override → org override → env default → None.

Org-level exemptions use -1 to mean unlimited (NULL inherits the
deployment default). This allows paying SaaS orgs to be exempted from
daily conversation limits while still enforcing limits for other orgs.

Add PUT /api/admin/quota/orgs/{org_id}/quota admin endpoint for
setting or clearing org-level limits. 6 focused tests covering
user precedence, org override, org exemption, env fallback, and
unset/unlimited resolution.

Stacked on feat/quota-usage-page (PR #199).

Co-authored-by: openhands <openhands@all-hands.dev>
@github-actions

github-actions Bot commented Aug 20, 2026

Copy link
Copy Markdown

Coverage report

Click to see where and how coverage changed

FileStatementsMissingCoverageCoverage
(new stmts)
Lines missing
  enterprise
  saas_server.py 54-59, 213-217
  enterprise/server/auth
  authorization.py
  enterprise/server/routes
  quota.py 44-47, 83-85, 115-135
  enterprise/server/services
  daily_conversation_quota_service.py 46, 49-52, 80-90, 95
  enterprise/storage
  org.py
Project Total  

This report was generated by python-coverage-comment-action

hieptl added 6 commits August 20, 2026 22:32
Main advanced through migrations 148/149 (budget changes), so this
branch's migration reused the already-applied revision id 148. Renumber
it to 150 on top of main's 149.

Also drop the OH_DAILY_CONVERSATION_LIMIT backfill: stamping the
deployment default into every existing user's daily_conversation_limit
would take precedence over org-level limits/exemptions and future
default changes, permanently pinning those users. NULL now always means
'inherit the effective default at runtime'.
Return undefined explicitly from the countdown effect's no-op branch
(consistent-return alongside the interval cleanup return), apply
Prettier formatting to the SaaS-only message and countdown span, and
swap the unsupported pt-BR translations of the quota keys for the
required Catalan (ca) entries.
The revision id '149_org_quota' broke the numeric-prefix/revision match
rule and, together with the quota-request migration, created a second
Alembic head off revision 148. Renumber to a linear 151 on top of the
renumbered 150 so 'alembic upgrade head' resolves a single head.
@github-actions

Copy link
Copy Markdown

⚠️ This PR contains migrations. Please synchronize before merging to prevent conflicts.

hieptl added 6 commits August 20, 2026 22:52
The enterprise ruff config enforces single-quoted strings; this literal
was the one remaining violation failing the enterprise lint job.
Sort the quota router imports, drop the unused AsyncSession and patch
imports. These were previously masked in CI because the enterprise lint
job failed to build its mypy environment before ruff could report.
…ions

# Conflicts:
#	enterprise/saas_server.py
#	enterprise/server/routes/quota.py
Drop the unused logger import and apply ruff formatting in the quota
routes and org quota test; restore the patch import the upstream merge
removed while this branch's env-clearing test still uses it. These were
previously masked in CI by the enterprise lint job's environment
failure.
Base automatically changed from feat/quota-usage-page to main August 24, 2026 16:23
@neubig
neubig requested a review from hieptl August 24, 2026 16:32
hieptl added 4 commits August 24, 2026 23:36
Scope quota resolution to the request's effective org, gate the admin API
through the shared permission check, validate limit values, and guard the
migration revision graph.

- Resolve the daily conversation limit against the effective org
  (X-Org-Id / API-key binding) instead of user.current_org_id. The latter
  is only the user's last-selected org, so a multi-org user got the wrong
  org's limit -- and the wrong org's exemption -- whenever a request was
  scoped elsewhere.

- Replace the hand-rolled super-role check on
  PUT /api/admin/quota/orgs/{org_id}/quota with
  require_permission(MANAGE_ORG_QUOTA). The inline check skipped the
  API-key organization binding, letting a key bound to one org edit
  another org's quota. MANAGE_ORG_QUOTA is granted only to the superadmin
  super role; no org-scoped role carries it.

- Reject 0 and values below -1 on the admin API. They are not meaningful
  quotas but resolve to a limit no org can satisfy, silently blocking
  every member -- a mistyped "-11" for "-1" would have done the opposite
  of the intended exemption. Treat -1 as "exempt" at the user level too so
  the sentinel means the same thing at both levels.

- Type the path org_id as UUID so a malformed id is a 422, not a 500.

- Add test_migration_graph.py: duplicate revision ids, multiple heads,
  missing parents and shared parents now fail loudly. Sequential numbering
  means concurrent branches pick the same number and each passes CI alone,
  so the collision otherwise only surfaces on main after the second merge.

- Cover the admin route and its authorization gate, and pin the real
  user -> org -> usage query sequence in get_status.
Exempting an org from daily conversation limits is revenue-affecting, but
the endpoint recorded nothing, so there was no way to answer who changed
an org's quota or when.

Bind the caller id that require_permission already returns and emit
org_quota:set after the commit, matching the super_admins:grant /
super_admins:revoke convention for instance-admin mutations.

@hieptl hieptl left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thank you! 🙏

@hieptl
hieptl merged commit 2b0892e into main Aug 24, 2026
21 checks passed
@hieptl
hieptl deleted the feat/org-level-quota-exemptions branch August 24, 2026 17:56
@openhands-release-bot openhands-release-bot Bot added the released: 1.55.0 Shipped in 1.55.0 label Aug 25, 2026
@openhands-release-bot

Copy link
Copy Markdown

🚀 Released in 1.55.0.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

released: 1.55.0 Shipped in 1.55.0 type: feat A new feature

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants