Skip to content

feat(usage): per-person AI allowances — server cap + workspace limits, enforced at the model scope - #567

Merged
Deodat-Lawson merged 9 commits into
mainfrom
claude/per-user-quota-limits-5e23fb
Oct 27, 2026
Merged

Deodat-Lawson merged 9 commits into
mainfrom
claude/per-user-quota-limits-5e23fb

Conversation

@Deodat-Lawson

@Deodat-Lawson Deodat-Lawson commented Oct 27, 2026 •

Copy link
Copy Markdown
Owner

What this does

Adds per-person AI allowances. Every AI model call is now recorded against the person it was for, and a person who reaches their monthly limit is refused new AI work with a sentence that says which limit, when it resets and who can raise it.

Two limits can apply. The tighter one binds. Both count AI tokens in + out (as Workspace › Usage and costs reports them) by calendar month, UTC.

Limit Set by Counts Notes
Server cap AI_MONTHLY_TOKEN_LIMIT_PER_USER (env) the person's calls on the server's own models, across all their workspaces AI_TOKEN_LIMIT_EXEMPT_EMAILS are never capped. Doesn't apply while a workspace runs on its own endpoint. Unset/0 = off.
Workspace limit admins with settings.manage, in Workspace › Usage and costs › AI limits per person the person's calls in that workspace, whoever's endpoint served them a default for everyone (usage.memberMonthlyTokenLimit, 0 = none) plus a per-person override (none, or N tokens)

The server cap exists because anyone who creates a workspace owns it, so a workspace limit alone can't protect the hosted model bill.

With nothing configured, nothing changes. Both limits are off by default, in self-hosted and cloud mode.

How it works

  • Recording. ai_usage_daily gains user_id (in the row key, declared last for the CI column-order check). withWorkspaceChatModels(companyId, use, fn) now takes { feature, userId, enforce } where it took a bare feature. A bare feature still means "work no one person started".
  • Attribution.
    • Request handlers pass the signed-in person.
    • Inngest jobs, after() continuations and the company-profile rebuild pass whoever started them: from event data (userId added to the predictive-analysis, repo-explainer and scenario events, optional for events already queued), from the run row, or from createdByActorId.
    • Meetings count against the meeting's creator.
    • The worker's indexing counts against the source's uploader (document_versions.uploaded_by).
  • Enforcement, fail-closed. At a limit the model scope's error is an AiAllowanceExceededError (a 429 ChatScopeUnavailableError), so every model resolution inside throws. describeChatResolutionFailure and both shared handleRouteErrors word it for the screen.
  • Enforcement, up front. 21 request routes and 8 run-start routes (prospects, distribution, client prospector, trends, repo explainer, predictive analysis, weekly reviews) call refuseAtAiAllowance first, so they answer 429 { code: "ai_allowance_reached" } before doing or queueing any work. Proposals and scenarios throw from their services.
  • Running work is never stopped. Background steps, continuations, meetings and indexing pass enforce: false: they're counted, not refused. The check runs when a request starts, so one request can carry someone slightly past the limit.
  • Limit storage. Overrides live in the new member_ai_limits table (row absent = workspace default, null = no limit), not in the settings member scope, which is "your own preference" and unaudited. Changes are audited as settings.changed.
  • New routes:
    • GET /api/ai-allowance returns your own allowance (any member).
    • GET /api/settings/usage/members (analytics.view) returns each member's use and limits.
    • PUT /api/settings/usage/members/[userId] (settings.manage) sets one person's limit.
  • UI (English + Simplified Chinese):
    • Workspace › Usage and costs: a new AI limits per person block with the default row, a server-cap note, a per-member table and an Edit limit dialog.
    • Settings › Account: a new Your AI allowance this month card.
    • Ask chat: an amber line from 80%; at the limit a red line and sending stops; a refused answer offers no Retry.
  • Docs: docs/deployment.md (new section) and .env.example.

Verification

  • tsc --noEmit is clean for web and worker. Lint is clean on every changed file; the four warnings left existed on main.
  • New tests:
    • unit tests for the allowance maths, the scope refusal and the refusal wording;
    • route tests, with both new admin routes added to permission-gates.test.ts;
    • a real-Postgres integration test: per-person rows, the server cap across workspaces on the server's models only, exempt emails, overrides;
    • 26 UI tests.
  • On the merged tree: 113 suites, 1,408 tests pass. The full web suite passed with DATABASE_URL set, except five DB integration suites that time out under parallel workers against a shared database; they pass with --runInBand, which is how CI runs them.
  • Live in a worktree preview (private migrated DB, an instant stand-in model so no tokens were spent), as the seeded admin/member/owner:
    • the member's calls landed in ai_usage_daily under their id;
    • at 93% the request ran; the next one got a 429 in chat, in the document writer and in trend search;
    • the admin was unaffected;
    • lifting the limit restored access;
    • with the server cap set to 12,000, the member was refused with the server wording and the exempt owner wasn't;
    • screens checked at 1440px and 375px (no page overflow), light, dark, and Chinese.

For the reviewer

  • Uploads are not refused at a limit, only counted: refusing would block adding files over an AI side effect. Easy to flip if you'd rather refuse.
  • Meeting turns count against the meeting's creator, since anyone in the room or the Slack channel can trigger one.
  • A failed allowance read lets the work run (it's a guard, not billing).
  • Usage before this ships was never attributed, so allowances start counting at upgrade.
  • Migration 20261027054725_member_ai_allowances (new table, new column + index, row-key index rebuilt) needs to run on deploy. Nothing is deployed. It was regenerated after main's 20261026001358_feedback (feat(feedback): in-app bug reports and feature requests for the testing phase #568) so the snapshot chain stays linear; the SQL is byte-identical to the original, and both drift probes are clean.
  • packages/llm: ChatScopeUnavailableError.status widened from the literal 503 to number, so a host can subclass it with its own status.

🤖 Generated with Claude Code


Note

Medium Risk
Broad changes across AI entry points and metering schema affect billing-adjacent behavior, though limits default off, failed quota reads fail open, and coverage includes integration and UI tests.

Overview
Introduces per-person monthly AI token allowances with two independent caps (server-wide on hosted models vs workspace default + per-member overrides). Usage is attributed via user_id on daily AI usage rows and enforced before work starts (refuseAtAiAllowance → HTTP 429 ai_allowance_reached) and inside withWorkspaceChatModels so model resolution fails at the limit.

Data & config: migration adds pdr_ai_v2_member_ai_limits and user_id on ai_usage_daily; .env.example documents AI_MONTHLY_TOKEN_LIMIT_PER_USER and exempt emails.

API surface: GET /api/ai-allowance (self), GET/PUT /api/settings/usage/members (analytics.view / settings.manage). Many existing AI routes gain the upfront refusal gate and pass { feature, userId, enforce? } into the model scope; background/scenario paths use enforce: false so in-flight work still counts but is not blocked.

Product UI: Workspace › Usage gets per-member limits table and edit dialog; Account shows personal allowance; Ask chat shows warn/block notices, disables send at limit, and suppresses useless retries on allowance errors.

Reviewed by Cursor Bugbot for commit d8d7315. Bugbot is set up for automated code reviews on this repo. Configure here.

Deodat-Lawson and others added 8 commits October 25, 2026 20:22
…del scope

Every model call is now recorded against the person it was for: the meter
row key (ai_usage_daily) gains user_id, and withWorkspaceChatModels takes
{ feature, userId, enforce } in place of a bare feature. Request handlers
pass the signed-in person; Inngest jobs, after() continuations, meetings
and the worker's indexing pass whoever started the work (event data, the
run row, the meeting's creator, the source's uploader) with enforce:false,
so running work is counted and never stopped midway.

Two limits, the tighter binds, by calendar month in UTC:
- AI_MONTHLY_TOKEN_LIMIT_PER_USER caps one person's tokens on the server's
  own models across every workspace they belong to (calls to a workspace's
  own endpoint never count; AI_TOKEN_LIMIT_EXEMPT_EMAILS are never capped).
- usage.memberMonthlyTokenLimit is a workspace default, and
  member_ai_limits holds an admin's override for one person (none, or N).

A person at a limit gets a scope whose every model resolution throws
AiAllowanceExceededError (429), which describeChatResolutionFailure and
both shared route error handlers word for the screen. Request routes and
run-start routes (prospects, distribution, client prospector, trends,
repo explainer, predictive analysis, weekly reviews, proposals, scenarios)
also check first, so they answer 429 before doing or queueing any work.
Uploads are not refused; their indexing is counted.

New routes: GET /api/ai-allowance (your own), GET
/api/settings/usage/members (analytics.view) and PUT
/api/settings/usage/members/[userId] (settings.manage, audited).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…starter in its test

The allowance readout ran through the few-second cache meant for checks that
re-enter during one request, so the chat's notice lagged an answer behind.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…lowance, the chat's notice

Settings › Usage and costs gets "AI limits per person": the workspace
default (usage.memberMonthlyTokenLimit), a read-only note for the server's
cap, and a table of each member's use this month against the limit that
binds them, with an Edit limit dialog (workspace default / no limit /
custom) for settings.manage.

Settings › Account gets "Your AI allowance this month": used of limit, the
reset date, and who can change it.

The Ask chat reads the allowance on mount and after each answer: from 80%
an amber line above the composer, at the limit a red one and sending
stops. A refused answer (code ai_allowance_reached, threaded through
useAIChat into ThreadMessage.failureCode) offers no Retry.

Copy in English and Simplified Chinese.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
9K of a 2M cap read as 0%, as if unused. At the limit the composer's notice
already says it, so the thread's error bar no longer repeats it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Their fake database answers selects from a queue; the allowance read took
the document lookup's rows.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…-limits-5e23fb

# Conflicts:
#	apps/web/src/i18n/messages/en/errors.json
#	apps/web/src/i18n/messages/zh-CN/errors.json
Main moved workspace settings into the Workspace tab (#560); the refusal,
the chat notice, the allowance card and the docs still sent people to
Settings › Usage and costs.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@vercel

vercel Bot commented Oct 27, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

1 Skipped Deployment
Project Deployment Actions Updated
pdr-ai-v2 Ignored Ignored Oct 27, 2026 5:34am UTC

@cursor

cursor Bot commented Oct 27, 2026

Copy link
Copy Markdown

Bugbot couldn't run - usage limit reached

Bugbot is counted against Cursor usage for this user or team, and this run hit a usage or spend limit.

A user or team admin can review and increase usage limits in the Cursor dashboard.

(requestId: 87b8a155-13bf-476c-a21a-bbefdd1b8895)

@chatgpt-codex-connector

chatgpt-codex-connector Bot commented Oct 27, 2026 •

Copy link
Copy Markdown

Codex Review Summary

This comment shows the latest Codex review activity on this pull request.

Review Status Commit Review trigger
📝 Code Review ✅ Completed 2026-10-27T05:37:31.363780Z be50f7c PR opened
ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review" or "@codex security review".

Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: be50f7cf38

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

function workspaceLocalRuntime(companyId: bigint): AgentRuntime {
function workspaceLocalRuntime(companyId: bigint, createdBy: string | null): AgentRuntime {
const local = getLocalRuntime();
const aiUse = { feature: "meetings", userId: createdBy, enforce: false } as const;

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Check the allowance before starting a new meeting

A member who has already exhausted either allowance can still create new meetings and run model-backed turns indefinitely. In the inspected POST /api/collab/meetings flow, neither the route nor createMeetingForCompany checks the allowance, and this unconditional enforce: false also exempts the first turn of every newly created meeting. Consequently, the server cap does not protect the hosted model bill through this path. Check the creator's allowance before starting new meetings while preserving the exemption for meetings already underway.

Useful? React with 👍 / 👎.

const aiUse = {
feature: "companyProfile",
userId: changedBy,
enforce: false,

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Gate explicitly requested profile rebuilds before scheduling

When a member is already at their allowance and the workspace has sources requiring model extraction, POST /api/company/profile still schedules a new rebuild: the inspected route calls startRebuild without an allowance check, and this scope disables enforcement unconditionally. This allows new, explicitly requested AI work to consume server tokens after the cap is reached; it is not merely letting previously accepted work finish. Check the allowance in startRebuild before marking the profile as building and scheduling the continuation, retaining enforce: false inside the accepted background job.

Useful? React with 👍 / 👎.

…-limits-5e23fb

# Conflicts:
#	apps/web/drizzle/meta/_journal.json
#	apps/web/src/i18n/messages/en/errors.json
#	apps/web/src/i18n/messages/zh-CN/errors.json
@cursor

cursor Bot commented Oct 27, 2026

Copy link
Copy Markdown

Bugbot couldn't run - usage limit reached

Bugbot is counted against Cursor usage for this user or team, and this run hit a usage or spend limit.

A user or team admin can review and increase usage limits in the Cursor dashboard.

(requestId: 7236aae3-bbdc-446d-9210-3c25c2221e61)

@Deodat-Lawson
Deodat-Lawson merged commit 35f4219 into main Oct 27, 2026
13 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant