You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
{{ message }}
Repository navigation
feat(cloud): Enforce per-plan AI token allowance #9
The cloud plugin should enforce a per-plan AI token allowance by implementing the usage half of the Entitlements seam. Core already gives the plugin what it needs: it gates each turn with remaining($owner, 'ai_tokens') and meters with consume($owner, 'ai_tokens', $n), both keyed on the polymorphic owner. The plugin supplies the counter storage, resolves the owner's allowance, and binds its implementation over the default AllowAllEntitlements.
Two things aren't in place yet: the owner's active plan is never persisted (plans exist only as marketing config), and core meters the agent route only, so a user can spend tokens on other routes without touching their allowance.
Checklist
Persist the owner's active plan in the plugin so remaining() can resolve an allowance (link an existing plan/subscription issue if one already covers this).
Add a token_allowance and a monthly reset period to each plan.
Create ai_usage_counters in the plugin, keyed polymorphically: morphs('owner'), period_start, tokens_used, unique(owner_type, owner_id, period_start).
Implement remaining('ai_tokens') as allowance minus current-period usage and consume('ai_tokens', n) as an increment. Bind over the default.
(core) Meter every token-spending path. The general and SmartQL replies and the per-turn classify and title calls currently call neither consume() nor record usage; only the agent route does.
(core) Fire consume() at most once per assistant message, keyed on the message id core already owns, so a job retry doesn't double-count.
Acceptance
A user over their allowance gets a quota-exceeded response from POST /api/v1/ai/chat.
Usage rolls over monthly.
A deployment with the cloud plugin disabled stays unlimited.
A core test confirms the counter matches the tokens core recorded for a turn.
Also the consume method takes just the key and number of tokens.
It's dificult to get the particular chat id from the cloud plugin, except we add a third parameter to the consume method
The counter equals the summed usage stored in ChatMessage.result['usage'] and rolls over monthly.
... as an idempotent increment keyed by chat message id, inside the cloud Entitlements implementation.
and use the remaining entitlement method to determine if a message can be sent or not
Everything the plugin needs from core is already there: remaining() gates each turn and consume() meters it, both keyed on the polymorphic owner. So the plugin side is storage, allowance lookup, and the binding. Your three questions:
Question
Answer
Current subscription
Not persisted yet, plans are just config. Since plans are the plugin's domain, storing the owner's active plan is part of this issue, and remaining() resolves the allowance from it.
consume needs the chat id
You don't need it. Core sums the tokens and calls consume($owner, 'ai_tokens', $n) once per turn; the once-per-message guard lives in core, keyed on the message id it already owns. The seam stays (owner, meter, amount).
Extract into core
Already there. The gate and the meter call both live in core; only the counter storage and allowance lookup go in the plugin.
One core bug to fix while you're in there: only the agent route meters. The general and SmartQL replies, and the per-turn classify and title calls, spend tokens but never call consume(). Wire them in too, otherwise a user can exhaust the model without touching their allowance.
Two things to avoid: don't read ChatMessage.result['usage'] from the plugin (core hands you the number through consume()), and key the counter table polymorphically (morphs('owner')), not user_id.
Activity
kofimokome commentedon Jun 22, 2026
@nfebe how do I get the user's current subscription?
kofimokome commentedon Jun 22, 2026
Also the consume method takes just the key and number of tokens.
It's dificult to get the particular chat id from the cloud plugin, except we add a third parameter to the consume method
kofimokome commentedon Jun 30, 2026
We can extract this into core
and use the
remainingentitlement method to determine if a message can be sent or notnfebe commentedon Jul 4, 2026
Everything the plugin needs from core is already there:
remaining()gates each turn andconsume()meters it, both keyed on the polymorphic owner. So the plugin side is storage, allowance lookup, and the binding. Your three questions:remaining()resolves the allowance from it.consumeneeds the chat idconsume($owner, 'ai_tokens', $n)once per turn; the once-per-message guard lives in core, keyed on the message id it already owns. The seam stays(owner, meter, amount).One core bug to fix while you're in there: only the agent route meters. The general and SmartQL replies, and the per-turn classify and title calls, spend tokens but never call
consume(). Wire them in too, otherwise a user can exhaust the model without touching their allowance.Two things to avoid: don't read
ChatMessage.result['usage']from the plugin (core hands you the number throughconsume()), and key the counter table polymorphically (morphs('owner')), notuser_id.