Skip to content

feat(cloud): Enforce per-plan AI token allowance #9

Open

Description

@nfebe

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.

Activity

  1. kofimokome commented on Jun 22, 2026

    @kofimokome
    Collaborator

    @nfebe how do I get the user's current subscription?

  2. kofimokome commented on Jun 22, 2026

    @kofimokome
    Collaborator

    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

  3. kofimokome commented on Jun 30, 2026

    @kofimokome
    Collaborator

    We can extract this into core

    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

  4. nfebe commented on Jul 4, 2026

    @nfebe
    ContributorAuthor

    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.

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

Metadata

Metadata

Assignees

Labels

No labels
No labels

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions