feat: cap per-user AI token spend
CI and Deploy / test (push) Successful in 2m43s
CI and Deploy / deploy (push) Successful in 57s

This commit is contained in:
cesnimda
2026-07-30 23:32:13 +02:00
parent 09fc2b03f7
commit 792814b04e
5 changed files with 18 additions and 9 deletions
+1
View File
@@ -14,6 +14,7 @@ public sealed class AccountPlansTests
Assert.False(free.AdvancedAi); Assert.False(free.AdvancedAi);
Assert.True(premium.AdvancedAi); Assert.True(premium.AdvancedAi);
Assert.True(premium.MonthlyAiCalls > free.MonthlyAiCalls); Assert.True(premium.MonthlyAiCalls > free.MonthlyAiCalls);
Assert.True(premium.MonthlyAiTokens > free.MonthlyAiTokens);
Assert.True(premium.StorageBytes > free.StorageBytes); 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 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] [HttpGet]
public async Task<ActionResult<UsageDto>> Get(CancellationToken cancellationToken) 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 && x.CreatedAtUtc >= monthStart), cancellationToken),
await SumAsync(_db.AiInteractions.Where(x => x.OwnerUserId == user.Id), cancellationToken), await SumAsync(_db.AiInteractions.Where(x => x.OwnerUserId == user.Id), cancellationToken),
entitlements.AdvancedAi ? "premium" : "free", entitlements.AdvancedAi ? "premium" : "free",
entitlements.MonthlyAiCalls)); entitlements.MonthlyAiCalls,
entitlements.MonthlyAiTokens));
} }
private static async Task<UsagePeriodDto> SumAsync(IQueryable<AiInteraction> query, CancellationToken cancellationToken) 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) if (_db is not null)
{ {
var roles = await _users.GetRolesAsync(user); 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 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); var used = await _db.AiInteractions
if (used >= limit) return StatusCode(StatusCodes.Status429TooManyRequests, $"Monthly AI limit reached ({limit} generations). Upgrade your plan or try again next month."); .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 try
+3 -3
View File
@@ -1,6 +1,6 @@
namespace JobTrackerApi.Models; 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 public static class AccountPlans
{ {
@@ -8,7 +8,7 @@ public static class AccountPlans
{ {
var premium = roles.Contains("Premium", StringComparer.OrdinalIgnoreCase) || roles.Contains("Admin", StringComparer.OrdinalIgnoreCase); var premium = roles.Contains("Premium", StringComparer.OrdinalIgnoreCase) || roles.Contains("Admin", StringComparer.OrdinalIgnoreCase);
return premium return premium
? new AccountEntitlements(true, true, true, true, 5_000_000_000, 250) ? new AccountEntitlements(true, true, true, true, 5_000_000_000, 250, 1_000_000)
: new AccountEntitlements(false, false, false, false, 250_000_000, 25); : new AccountEntitlements(false, false, false, false, 250_000_000, 25, 100_000);
} }
} }
+1 -1
View File
@@ -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.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.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.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. |
--- ---