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.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
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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. |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user