feat: cap per-user AI token spend
This commit is contained in:
@@ -14,6 +14,7 @@ public sealed class AccountPlansTests
|
||||
Assert.False(free.AdvancedAi);
|
||||
Assert.True(premium.AdvancedAi);
|
||||
Assert.True(premium.MonthlyAiCalls > free.MonthlyAiCalls);
|
||||
Assert.True(premium.MonthlyAiTokens > free.MonthlyAiTokens);
|
||||
Assert.True(premium.StorageBytes > free.StorageBytes);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -22,7 +22,7 @@ public sealed class AiUsageController : ControllerBase
|
||||
}
|
||||
|
||||
public sealed record UsagePeriodDto(int Calls, long InputCharacters, long OutputCharacters, long EstimatedTokens);
|
||||
public sealed record UsageDto(UsagePeriodDto CurrentMonth, UsagePeriodDto AllTime, string Plan, int MonthlyCallLimit);
|
||||
public sealed record UsageDto(UsagePeriodDto CurrentMonth, UsagePeriodDto AllTime, string Plan, int MonthlyCallLimit, long MonthlyTokenLimit);
|
||||
|
||||
[HttpGet]
|
||||
public async Task<ActionResult<UsageDto>> Get(CancellationToken cancellationToken)
|
||||
@@ -37,7 +37,8 @@ public sealed class AiUsageController : ControllerBase
|
||||
await SumAsync(_db.AiInteractions.Where(x => x.OwnerUserId == user.Id && x.CreatedAtUtc >= monthStart), cancellationToken),
|
||||
await SumAsync(_db.AiInteractions.Where(x => x.OwnerUserId == user.Id), cancellationToken),
|
||||
entitlements.AdvancedAi ? "premium" : "free",
|
||||
entitlements.MonthlyAiCalls));
|
||||
entitlements.MonthlyAiCalls,
|
||||
entitlements.MonthlyAiTokens));
|
||||
}
|
||||
|
||||
private static async Task<UsagePeriodDto> SumAsync(IQueryable<AiInteraction> query, CancellationToken cancellationToken)
|
||||
|
||||
@@ -44,10 +44,17 @@ public sealed class AiWorkspaceController : ControllerBase
|
||||
if (_db is not null)
|
||||
{
|
||||
var roles = await _users.GetRolesAsync(user);
|
||||
var limit = AccountPlans.ForRoles(roles).MonthlyAiCalls;
|
||||
var entitlements = AccountPlans.ForRoles(roles);
|
||||
var monthStart = new DateTimeOffset(DateTime.UtcNow.Year, DateTime.UtcNow.Month, 1, 0, 0, 0, TimeSpan.Zero);
|
||||
var used = await _db.AiInteractions.CountAsync(x => x.OwnerUserId == user.Id && x.CreatedAtUtc >= monthStart, ct);
|
||||
if (used >= limit) return StatusCode(StatusCodes.Status429TooManyRequests, $"Monthly AI limit reached ({limit} generations). Upgrade your plan or try again next month.");
|
||||
var used = await _db.AiInteractions
|
||||
.Where(x => x.OwnerUserId == user.Id && x.CreatedAtUtc >= monthStart)
|
||||
.GroupBy(_ => 1)
|
||||
.Select(g => new { Calls = g.Count(), Tokens = g.Sum(x => (long)x.EstimatedTokenCount) })
|
||||
.FirstOrDefaultAsync(ct);
|
||||
if ((used?.Calls ?? 0) >= entitlements.MonthlyAiCalls)
|
||||
return StatusCode(StatusCodes.Status429TooManyRequests, $"Monthly AI limit reached ({entitlements.MonthlyAiCalls} generations). Upgrade your plan or try again next month.");
|
||||
if ((used?.Tokens ?? 0) >= entitlements.MonthlyAiTokens)
|
||||
return StatusCode(StatusCodes.Status429TooManyRequests, $"Monthly AI cost limit reached ({entitlements.MonthlyAiTokens:N0} estimated tokens). Upgrade your plan or try again next month.");
|
||||
}
|
||||
|
||||
try
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
namespace JobTrackerApi.Models;
|
||||
|
||||
public sealed record AccountEntitlements(bool AdvancedAi, bool PremiumThemes, bool Automation, bool Analytics, long StorageBytes, int MonthlyAiCalls);
|
||||
public sealed record AccountEntitlements(bool AdvancedAi, bool PremiumThemes, bool Automation, bool Analytics, long StorageBytes, int MonthlyAiCalls, long MonthlyAiTokens);
|
||||
|
||||
public static class AccountPlans
|
||||
{
|
||||
@@ -8,7 +8,7 @@ public static class AccountPlans
|
||||
{
|
||||
var premium = roles.Contains("Premium", StringComparer.OrdinalIgnoreCase) || roles.Contains("Admin", StringComparer.OrdinalIgnoreCase);
|
||||
return premium
|
||||
? new AccountEntitlements(true, true, true, true, 5_000_000_000, 250)
|
||||
: new AccountEntitlements(false, false, false, false, 250_000_000, 25);
|
||||
? new AccountEntitlements(true, true, true, true, 5_000_000_000, 250, 1_000_000)
|
||||
: new AccountEntitlements(false, false, false, false, 250_000_000, 25, 100_000);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -197,7 +197,7 @@ Goal: commercialise. Last, per the guide's "do not over-engineer before needed.
|
||||
| 7.6 | ✅ **DONE (2026-07-30)** — public CV (`/cv/{guid}`), privacy-first random links, revoke/rotate sharing | **P3** | **M** | 3.4, 4.2 | Anonymous rendering is isolated behind an explicit public flag, served with `noindex`, and revoked links cannot be restored accidentally. |
|
||||
| 7.7 | ✅ **DONE (2026-07-30)** — three free CV themes plus five Premium themes, enforced by account entitlement and clearly locked in the picker | **P3** | **S** | 4.3, 7.2 | Existing Premium-theme CVs remain editable and exportable after downgrade so user data is never held hostage. |
|
||||
| 7.8 | **DONE (2026-07-30)** — CI runs NuGet transitive vulnerability reporting and a production-only npm audit. The npm audit reports the existing no-fix advisory baseline without blocking unrelated deploys. | **P2** | **S** | none | Vulnerable dependencies are now visible before deployment. |
|
||||
| 7.9 | **Per-user AI provider cost controls** | **P3** | **S** | 5.2, 7.2 | With `AI_PROVIDER=gemini` the "advanced AI" tier spends real money per call. Metering (5.2) measures; this enforces. |
|
||||
| 7.9 | ✅ **DONE (2026-07-30)** — per-user monthly AI token ceilings (100k free, 1M Premium/Admin) enforced alongside generation limits and exposed in usage totals | **P3** | **S** | 5.2, 7.2 | Existing metering is the single accounting source; paid-provider spend now has both request and token ceilings. |
|
||||
|
||||
---
|
||||
|
||||
|
||||
Reference in New Issue
Block a user