Skip to content

feat: Configure AI usage counter - #11

Merged
nfebe merged 9 commits into
mainfrom
feat/ai-allowance
Aug 24, 2026
Merged

feat: Configure AI usage counter#11
nfebe merged 9 commits into
mainfrom
feat/ai-allowance

Conversation

@kofimokome

@kofimokome kofimokome commented Jun 28, 2026

Copy link
Copy Markdown
Collaborator

Fixes #7

Depends on #10

@kofimokome
kofimokome marked this pull request as draft June 28, 2026 16:52
@sourceant

sourceant Bot commented Jun 28, 2026

Copy link
Copy Markdown

Code Review Summary

This PR configures the AI usage counter by integrating the Whilesmart\Entitlements package. It introduces automated plan synchronization and a fallback mechanism for users without active subscriptions.

🚀 Key Improvements

  • Introduced FreePlanFallbackSource to ensure non-subscribed users are bound by 'Free' plan limits rather than being 'unlimited' by default.
  • Separated marketing strings from machine-readable feature_keys, limits, and meters in cloudplans.php.
  • Configured TokenMeterUsage to read directly from core usage records to prevent data drift.

💡 Minor Suggestions

  • Consider using a dedicated Interface or Trait check instead of method_exists in TokenMeterUsage for better type safety.

🚨 Critical Issues

  • The UsageMeter implementation bypasses tracking for any meter other than ai_tokens by returning INF regardless of the plan's allowance.
  • Integer casting in the sync process makes it impossible to define 'unlimited' token allowances.

@sourceant sourceant 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.

Review complete. See the overview comment for a summary.

Comment thread config/cloudplans.php Outdated
Comment thread src/Support/CloudEntitlements.php Outdated
@kofimokome
kofimokome marked this pull request as ready for review July 6, 2026 20:12

@sourceant sourceant 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.

Review complete. See the overview comment for a summary.

Comment thread src/Support/CloudEntitlements.php Outdated
Comment thread database/migrations/2019_05_03_000002_create_subscriptions_table.php Outdated
@kofimokome
kofimokome force-pushed the feat/ai-allowance branch from 35627ef to ea97334 Compare July 6, 2026 20:18

@sourceant sourceant 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.

Review complete. See the overview comment for a summary.

Comment thread src/Models/AiUsageCounter.php Outdated
@kofimokome
kofimokome force-pushed the feat/ai-allowance branch from ea97334 to d97f1f4 Compare July 6, 2026 20:21

@sourceant sourceant 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.

Review complete. See the overview comment for a summary.

Comment thread src/Http/Controllers/CloudController.php Outdated

@nfebe nfebe 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.

This looks good but since we have multiple projects that the question of tokens will come up. I built and publish a package that will simplify things there is already a task to implement it in the core and it may remove the need for one table you have here.

See: https://github.com/whilesmartphp/eloquent-agent-metrics

@kofimokome

Copy link
Copy Markdown
Collaborator Author

@nfebe what's the issue number on trakli core? I can't find it

@nfebe

nfebe commented Jul 15, 2026

Copy link
Copy Markdown
Contributor

I already implemented it so its closed

@sourceant sourceant 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.

Review complete. See the overview comment for a summary.

Comment thread src/Support/CloudEntitlements.php Outdated
@kofimokome
kofimokome requested a review from nfebe July 15, 2026 21:49
nfebe and others added 5 commits July 31, 2026 01:41
With this plugin enabled the gate is answered from the owner's plan
instead of allowing everything: features come from the plan the Stripe
webhook recorded, and wallet and category limits come with it. Turning
freemode on leaves the permissive default alone, so enforcement is one
switch.

An owner with no subscription is treated as being on the free plan.
Without that they would be refused every feature while their limits read
as unlimited, which is exactly backwards for a free tier.

The plan a request is gated against is read from the subscription rows
rather than from the payment provider, so no plan cache is kept and
nothing has to be invalidated when a subscription changes.

The AI allowance is carried on the plan but nothing counts against it
yet; that needs a usage meter and is not in this change.
# Conflicts:
#	src/CloudServiceProvider.php

# Conflicts:
#	src/CloudServiceProvider.php

@nfebe nfebe 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.

There is already a way to measure the usage in core

$table->unsignedBigInteger('user_id');
$table->morphs('owner');
$table->timestamp('period_start');
$table->integer('tokens_used');

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.

Could you add this to the webservice and see how we can eliminate this table?

https://github.com/whilesmartphp/eloquent-agent-metrics

Was this solved?

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

I'm still working on this and the other open pr.

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.

So I already integrated the metrics tracking in the core : https://github.com/trakli/webservice/blob/dev/composer.json#L26

So you just need to used it and remove the custom tracking tables

@nfebe

nfebe commented Aug 3, 2026

Copy link
Copy Markdown
Contributor

This doesn't load against current dev:

Error: Interface "App\Contracts\Entitlements" not found

Dropping the counter table is addressed, and tokensUsed($periodStart) is the right call on the trait. The rest went stale while the branch was open:

Issue Where
App\Contracts\Entitlements no longer exists; core gates through Whilesmart\Entitlements\Contracts\Entitlements removed in trakli/webservice#302
getPlanCode() queries BillingCustomer, which is gone deleted when #10 merged
Nothing binds CloudEntitlements, so it never runs binding was in #12
remaining() returns INF on the free plan, so free users get unlimited AI despite CLOUD_PLAN_FREE_TOKEN_ALLOWANCE defaulting to 0 CloudEntitlements::remaining()

Cache and DB are unused imports, and allows() / limit() return allow-all, which would switch the plan gating back off if this bound.

This should be a UsageMeter, not an Entitlements. The allowance belongs to the plan and the meter only counts against it, so the plan lookup, the Cashier query and the freemode branch all move out:

class TokenMeterUsage implements UsageMeter
{
    public function remaining(?Model $owner, string $meter, int|float $allowance): int|float
    {
        if ($meter !== 'ai_tokens' || $owner === null || is_infinite($allowance)) {
            return INF;
        }

        return max(0, $allowance - $owner->tokensUsed(now()->startOfMonth()));
    }

    public function consume(?Model $owner, string $meter, int $amount): void
    {
        // core already records through TokenMeter
    }
}

That needs plans carrying meters.ai_tokens, which is in feat/entitlement-rewired. Rebase onto that once it merges and this is the class above plus a binding.

@kofimokome

Copy link
Copy Markdown
Collaborator Author

@nfebe are we taking away the cloudplans.php config?

I see that most of the information in the config is in the Eloquent Entitlements plans table

@sourceant sourceant 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.

Review complete. See the overview comment for a summary.

Comment thread src/Support/CloudEntitlements.php Outdated
/**
* Determine if the owner is allowed to use a given feature.
*/
public function allows(?Model $owner, string $feature): bool

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

The allows method is currently hardcoded to return true, which bypasses all plan-based feature restrictions. This should delegate to the check method to ensure that entitlements are properly enforced across the application.

Suggested change
public function allows(?Model $owner, string $feature): bool
public function allows(?Model $owner, string $feature): bool
{
return $this->check($owner, $feature)->allowed();
}

Comment thread src/Support/CloudEntitlements.php Outdated
$planCode = $this->getPlanCode($owner);
$plan = config("cloudplans.plans.{$planCode}");

// Check if the feature is listed in the plan's features or permissions

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

The features array in config/cloudplans.php contains human-readable strings for the UI (e.g., 'Up to 3 wallets'). Checking for a programmatic feature key (like 'ai_chat') against this array will always fail. You should introduce a machine-readable permissions array in the config or map feature keys to these strings.

Suggested change
// Check if the feature is listed in the plan's features or permissions
// Suggested: Use a dedicated permissions array for programmatic keys
$permissions = $plan['permissions'] ?? [];
if ($plan && in_array($feature, $permissions, true)) {
return AccessResult::allow($feature);
}

Comment thread src/Support/CloudEntitlements.php Outdated
{
$subscription = $this->activeSubscriptionFor($owner);
if ($subscription && $subscription->plan) {
return explode('-', $subscription->plan->key)[0]; // gets monthly from monthly-eu

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Determining the plan code by splitting the key string is brittle. If a plan key doesn't follow the exact prefix-suffix format (e.g., a three-part key), this logic might return an incorrect value. Consider using a more robust mapping or Laravel's Str::before helper.

Suggested change
return explode('-', $subscription->plan->key)[0]; // gets monthly from monthly-eu
return (string) \Illuminate\Support\Str::before($subscription->plan->key, '-');

@kofimokome
kofimokome requested a review from nfebe August 19, 2026 21:22
@nfebe

nfebe commented Aug 19, 2026

Copy link
Copy Markdown
Contributor

Yes. We should remove the plan definitions from cloudplans.php. Names, features, limits, pricing metadata, and meters.ai_tokens belong in the entitlement plans table.

Configuration should retain only deployment flags and presentation copy that are not plan data. This PR should bind a UsageMeter and read the allowance supplied by PlanEntitlements; it should not introduce CloudEntitlements or another plan lookup.

@kofimokome

Copy link
Copy Markdown
Collaborator Author

Hi @nfebe I see this that the changes here feat/entitlement-rewired have not been merged. And this PR depends on it.

What is left to complete the work on that branch?

@nfebe

nfebe commented Aug 23, 2026

Copy link
Copy Markdown
Contributor

Can you link the PR instead of the branch? I am not on my desk maybe I can approve it.

@kofimokome

kofimokome commented Aug 23, 2026

Copy link
Copy Markdown
Collaborator Author

I don't know if the work you have done there is complete or is still in progress.

YOu haven't created a PR for it yet

@kofimokome

Copy link
Copy Markdown
Collaborator Author

I have created a PR for you => #13

nfebe added 2 commits August 24, 2026 10:46
…eat/ai-allowance

# Conflicts:
#	src/CloudServiceProvider.php
A chat turn now spends from the token allowance the owner's plan carries,
and a turn attempted past that allowance answers with a quota message
instead of calling the model.

The count comes from the usage core already records for every turn, so the
plugin keeps no counter of its own and the two cannot drift apart. The
allowance resets at the start of each calendar month whatever the billing
interval, so a yearly plan gets its allowance back monthly.

Features and limits are answered by the shared entitlements layer over the
same plan, so the plugin no longer carries its own copy of that logic.
Turning freemode on leaves metering off along with the rest of enforcement.

@sourceant sourceant 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.

Review complete. See the overview comment for a summary.

{
private const METER = 'ai_tokens';

public function remaining(?Model $owner, string $meter, int|float $allowance): int|float

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Returning INF when the meter name doesn't match is a 'fail open' bug. If a plan defines a limit for a different meter (e.g., 'api_calls'), this implementation will return infinity, effectively bypassing enforcement for that feature. It should instead return the provided $allowance, assuming zero usage has been recorded by this specific provider.

Suggested change
public function remaining(?Model $owner, string $meter, int|float $allowance): int|float
public function remaining(?Model $owner, string $meter, int|float $allowance): int|float
{
if (is_infinite($allowance)) {
return INF;
}
if ($meter !== self::METER) {
return $allowance;
}

Comment thread src/Support/CloudPlanSync.php Outdated
$plan->name = $name;
$plan->features = $this->features($source);
$plan->limits = $source['limits'] ?? [];
$plan->meters = ['ai_tokens' => (int) ($source['token_allowance'] ?? 0)];

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Aggressively casting token_allowance to (int) prevents the use of null to represent an unlimited allowance in the database. Combined with the (int) cast in the config file, it makes 'unlimited tokens' impossible to configure despite the is_infinite check in the meter logic.

Suggested change
$plan->meters = ['ai_tokens' => (int) ($source['token_allowance'] ?? 0)];
$plan->meters = ['ai_tokens' => $source['token_allowance'] ?? 0];

A meter this plugin does not count was reported as unlimited rather than
as untouched, so a plan that allowed a fixed number of anything else would
have been unenforceable.

A plan can now state an unlimited token allowance as null, the way its
limits already do. Leaving the allowance out still means none.

@sourceant sourceant 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.

Review complete. No specific code suggestions were generated. See the overview comment for a summary.

@nfebe
nfebe merged commit 1c41ea6 into main Aug 24, 2026
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.

feat(cloud): Add Stripe subscriptions via Cashier mirror

2 participants