Metering Module
Token usage tracking and quota enforcement
The Metering module tracks LLM token consumption and enforces per-user daily quotas. Source: packages/server/src/modules/metering/.
REST Endpoints
All endpoints are prefixed with /api/v1/metering and require ClerkAuthGuard.
| Method | Path | Description |
|---|---|---|
| GET | /metering/org | Org-level usage for billing period |
| GET | /metering/user?period=day|week|month | User's usage for the given calendar period (default day) |
| GET | /metering/quota | User's current quota status |
Metering Service (services/metering.service.ts)
recordLLMUsage(params)
Called by ChatService after every LLM response. Parameters:
| Field | Type | Description |
|---|---|---|
orgId | string | Organisation |
userId | string | User who made the request |
model | string | Model ID (e.g. claude-sonnet-4-20250514) |
inputTokens | number | Prompt tokens |
outputTokens | number | Completion tokens |
metadata | JSON? | Extra context (session ID, workspace, etc.) |
Cost is calculated based on model pricing from @cosmohq/core.
checkQuota(orgId, userId?)
Returns the current quota status. Called by QuotaGuard before every chat request.
Response:
{
allowed: boolean;
usedTokens: number;
limitTokens: number;
percentage: number; // 0–100
plan: Plan;
resetAt?: string; // ISO instant of the next UTC midnight — absent only on the unlimited (-1) branch
}Orgs whose plan has tokensPerUserPerDay === -1 always return allowed: true (unlimited quota). Note: the ENTERPRISE plan currently has a fixed daily limit of 10M tokens, same as PREMIUM.
resetAt is computed once, here, and threaded through everywhere the reset time is needed (QuotaGuard's exception payload, both usage views) — no caller should recompute "next UTC midnight" independently.
getOrgUsageSummary(orgId)
Aggregates token usage for the org across the current billing period:
{
totalInputTokens: number;
totalOutputTokens: number;
totalTokens: number;
totalCostUsd: number;
byModel: { model: string; tokens: number; cost: number }[];
byUser: { userId: string; tokens: number; cost: number }[];
}getUserUsageSummary(orgId, userId, period?)
Usage totals for the requesting user over the given calendar period — 'day' (default), 'week', or 'month':
{
period: { start: string; end: string; granularity: 'day' | 'week' | 'month' };
totalInputTokens: number;
totalOutputTokens: number;
totalTokens: number;
totalCostUsd: number;
count: number;
}'day' uses the same UTC midnight-to-midnight window as checkQuota (the only period with an enforced limit). 'week' is the UTC calendar week (Monday–Monday) and 'month' the UTC calendar month, both computed independently of the org's Subscription/billing period — these are informational rollups only, since plans.config.ts defines no weekly or monthly limit.
Quota Limits by Plan
| Plan | Daily Token Limit (per user) |
|---|---|
NO_PLAN | 0 (blocked) |
FREE | 1,000,000 |
STANDARD | 2,000,000 |
PREMIUM | 10,000,000 |
ENTERPRISE | 10,000,000 |
Quotas reset at midnight UTC each day (records are scoped by date in the query).
Usage Record Schema
| Field | Type | Description |
|---|---|---|
id | string | Primary key |
orgId | string | Organisation |
userId | string | User |
type | UsageType enum | Usage category (LLM_CHAT, LLM_AGENT, etc.) |
inputTokens | int | Input tokens |
outputTokens | int | Output tokens |
model | string? | Model ID (nullable) |
costUsd | Decimal | Cost in USD |
metadata | JSON? | Extra context |
createdAt | DateTime | Usage timestamp (used for date filtering) |
QuotaGuard
packages/server/src/common/guards/quota.guard.ts is applied to POST /api/v1/chat:
- Calls
MeteringService.checkQuota(orgId, userId). - If
allowed: false, throwsForbiddenException→HTTP 403with a structured body:{ statusCode: 403, code: 'QUOTA_EXCEEDED', message, usage, resetAt }.GlobalExceptionFilterpreserves an allowlisted subset of a structuredHttpExceptionbody —code,usage,resetAt,inviteId(not every field, and not just the flattenedmessage) — so clients can branch oncode: 'QUOTA_EXCEEDED'instead of string-matching the message. - If
allowed: true, the request proceeds to the chat handler.
The status stays 403 (not 429) — code is the intended discriminator, and status codes elsewhere in the desktop client (e.g. 401 for a revoked session) are narrowly-scoped special cases, not a general pattern to extend.
Proactive quota warning
recordLLMUsage also computes a threshold signal after writing each usage record — 'approaching_limit' at ≥80% of the daily limit, 'at_limit' at 100%. ChatService awaits this call (rather than fire-and-forget) so the signal can ride the chat stream's terminal done event as quotaWarning, letting a client warn the user before their next request gets blocked, without polling. Consumed by the desktop chat panel to show a one-time-per-crossing toast.