feat(ai): centralize durable usage
Add a content-free usage ledger with legacy backfill. Reserve Workspace and durable Strategy/CV work before execution so deleted history or duplicate admission cannot reset limits.
This commit is contained in:
@@ -1,5 +1,6 @@
|
||||
using JobTrackerApi.Data;
|
||||
using JobTrackerApi.Models;
|
||||
using JobTrackerApi.Services;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Identity;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
@@ -14,11 +15,13 @@ public sealed class AiUsageController : ControllerBase
|
||||
{
|
||||
private readonly UserManager<ApplicationUser> _users;
|
||||
private readonly JobTrackerContext _db;
|
||||
private readonly AiUsageMeter? _usage;
|
||||
|
||||
public AiUsageController(UserManager<ApplicationUser> users, JobTrackerContext db)
|
||||
public AiUsageController(UserManager<ApplicationUser> users, JobTrackerContext db, AiUsageMeter? usage = null)
|
||||
{
|
||||
_users = users;
|
||||
_db = db;
|
||||
_usage = usage;
|
||||
}
|
||||
|
||||
public sealed record UsagePeriodDto(int Calls, long InputCharacters, long OutputCharacters, long EstimatedTokens);
|
||||
@@ -32,38 +35,19 @@ public sealed class AiUsageController : ControllerBase
|
||||
|
||||
var roles = await _users.GetRolesAsync(user);
|
||||
var entitlements = AccountPlans.ForRoles(roles);
|
||||
var monthStart = new DateTimeOffset(DateTime.UtcNow.Year, DateTime.UtcNow.Month, 1, 0, 0, 0, TimeSpan.Zero);
|
||||
var interactions = _db.AiInteractions.Where(x => x.OwnerUserId == user.Id);
|
||||
var currentMonth = _db.Database.IsSqlite()
|
||||
? Sum((await interactions.ToListAsync(cancellationToken)).Where(x => x.CreatedAtUtc >= monthStart))
|
||||
: await SumAsync(interactions.Where(x => x.CreatedAtUtc >= monthStart), cancellationToken);
|
||||
var meter = _usage ?? new AiUsageMeter(_db, TimeProvider.System);
|
||||
var currentMonth = ToDto(await meter.CurrentMonthAsync(user.Id, cancellationToken));
|
||||
return Ok(new UsageDto(
|
||||
currentMonth,
|
||||
await SumAsync(interactions, cancellationToken),
|
||||
ToDto(await meter.AllTimeAsync(user.Id, cancellationToken)),
|
||||
AccountPlans.Name(entitlements),
|
||||
entitlements.MonthlyAiCalls,
|
||||
entitlements.MonthlyAiTokens,
|
||||
await _db.Attachments.Where(x => x.JobApplication.OwnerUserId == user.Id).SumAsync(x => (long?)x.FileSize, cancellationToken) ?? 0,
|
||||
await _db.Attachments.Where(x => x.JobApplication.OwnerUserId == user.Id)
|
||||
.SumAsync(x => (long?)x.FileSize, cancellationToken) ?? 0,
|
||||
entitlements.StorageBytes));
|
||||
}
|
||||
|
||||
private static async Task<UsagePeriodDto> SumAsync(IQueryable<AiInteraction> query, CancellationToken cancellationToken)
|
||||
{
|
||||
var totals = await query.GroupBy(_ => 1).Select(group => new UsagePeriodDto(
|
||||
group.Count(),
|
||||
group.Sum(x => (long)x.InputCharacterCount),
|
||||
group.Sum(x => (long)x.OutputCharacterCount),
|
||||
group.Sum(x => (long)x.EstimatedTokenCount))).FirstOrDefaultAsync(cancellationToken);
|
||||
return totals ?? new UsagePeriodDto(0, 0, 0, 0);
|
||||
}
|
||||
|
||||
private static UsagePeriodDto Sum(IEnumerable<AiInteraction> interactions)
|
||||
{
|
||||
var rows = interactions.ToList();
|
||||
return new UsagePeriodDto(
|
||||
rows.Count,
|
||||
rows.Sum(x => (long)x.InputCharacterCount),
|
||||
rows.Sum(x => (long)x.OutputCharacterCount),
|
||||
rows.Sum(x => (long)x.EstimatedTokenCount));
|
||||
}
|
||||
private static UsagePeriodDto ToDto(AiUsageTotals totals)
|
||||
=> new(totals.Calls, totals.InputCharacters, totals.OutputCharacters, totals.EstimatedTokens);
|
||||
}
|
||||
|
||||
@@ -19,13 +19,15 @@ public sealed class AiWorkspaceController : ControllerBase
|
||||
private readonly IAiWorkspaceService _workspace;
|
||||
private readonly IConfiguration _config;
|
||||
private readonly JobTrackerApi.Data.JobTrackerContext? _db;
|
||||
private readonly AiUsageMeter? _usage;
|
||||
|
||||
public AiWorkspaceController(UserManager<ApplicationUser> users, IAiWorkspaceService workspace, IConfiguration config, JobTrackerApi.Data.JobTrackerContext? db = null)
|
||||
public AiWorkspaceController(UserManager<ApplicationUser> users, IAiWorkspaceService workspace, IConfiguration config, JobTrackerApi.Data.JobTrackerContext? db = null, AiUsageMeter? usage = null)
|
||||
{
|
||||
_users = users;
|
||||
_workspace = workspace;
|
||||
_config = config;
|
||||
_db = db;
|
||||
_usage = usage;
|
||||
}
|
||||
|
||||
public sealed record GenerateRequest(string Module, string? Mode, string? ExtraContext);
|
||||
@@ -42,38 +44,29 @@ public sealed class AiWorkspaceController : ControllerBase
|
||||
if (user is null) return Unauthorized();
|
||||
if (string.IsNullOrWhiteSpace(request?.Module)) return BadRequest("Choose an AI module.");
|
||||
|
||||
AiUsageReservation? reservation = null;
|
||||
if (_db is not null)
|
||||
{
|
||||
var roles = await _users.GetRolesAsync(user);
|
||||
var entitlements = AccountPlans.ForRoles(roles);
|
||||
var monthStart = new DateTimeOffset(DateTime.UtcNow.Year, DateTime.UtcNow.Month, 1, 0, 0, 0, TimeSpan.Zero);
|
||||
var interactions = _db.AiInteractions.Where(x => x.OwnerUserId == user.Id);
|
||||
int usedCalls;
|
||||
long usedTokens;
|
||||
if (_db.Database.IsSqlite())
|
||||
var usage = _usage ?? new AiUsageMeter(_db, TimeProvider.System);
|
||||
var estimate = AiUsageMeter.ReservationFor($"workspace.{request.Module.Trim().ToLowerInvariant()}");
|
||||
try
|
||||
{
|
||||
var used = (await interactions
|
||||
.Select(x => new { x.CreatedAtUtc, x.EstimatedTokenCount })
|
||||
.ToListAsync(ct))
|
||||
.Where(x => x.CreatedAtUtc >= monthStart)
|
||||
.ToList();
|
||||
usedCalls = used.Count;
|
||||
usedTokens = used.Sum(x => (long)x.EstimatedTokenCount);
|
||||
reservation = await usage.ReserveAsync(
|
||||
user.Id,
|
||||
entitlements,
|
||||
"workspace",
|
||||
Guid.NewGuid().ToString("D"),
|
||||
$"workspace.{request.Module.Trim().ToLowerInvariant()}",
|
||||
estimate.InputCharacters,
|
||||
estimate.EstimatedTokens,
|
||||
ct);
|
||||
}
|
||||
else
|
||||
catch (AiUsageLimitException ex)
|
||||
{
|
||||
var used = await interactions
|
||||
.Where(x => x.CreatedAtUtc >= monthStart)
|
||||
.GroupBy(_ => 1)
|
||||
.Select(g => new { Calls = g.Count(), Tokens = g.Sum(x => (long)x.EstimatedTokenCount) })
|
||||
.FirstOrDefaultAsync(ct);
|
||||
usedCalls = used?.Calls ?? 0;
|
||||
usedTokens = used?.Tokens ?? 0;
|
||||
return StatusCode(StatusCodes.Status429TooManyRequests, ex.Message);
|
||||
}
|
||||
if (usedCalls >= entitlements.MonthlyAiCalls)
|
||||
return StatusCode(StatusCodes.Status429TooManyRequests, $"Monthly AI limit reached ({entitlements.MonthlyAiCalls} generations). Upgrade your plan or try again next month.");
|
||||
if (usedTokens >= 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
|
||||
@@ -81,10 +74,19 @@ public sealed class AiWorkspaceController : ControllerBase
|
||||
var interaction = await _workspace.GenerateAsync(
|
||||
user.Id, jobId, user.ProfileCvText ?? string.Empty, ResolveName(user),
|
||||
new AiGenerateRequest(request.Module, request.Mode, request.ExtraContext), ResolveProvider(), ct);
|
||||
return interaction is null ? NotFound() : Ok(ToDto(interaction));
|
||||
if (interaction is null)
|
||||
{
|
||||
if (reservation is not null) await (_usage ?? new AiUsageMeter(_db!, TimeProvider.System)).ReleaseAsync(reservation.Record.Id, ct);
|
||||
return NotFound();
|
||||
}
|
||||
if (reservation is not null)
|
||||
await (_usage ?? new AiUsageMeter(_db!, TimeProvider.System)).FinalizeAsync(
|
||||
reservation.Record.Id, interaction.InputCharacterCount, interaction.OutputCharacterCount, ct);
|
||||
return Ok(ToDto(interaction));
|
||||
}
|
||||
catch (ArgumentException ex)
|
||||
{
|
||||
if (reservation is not null) await (_usage ?? new AiUsageMeter(_db!, TimeProvider.System)).ReleaseAsync(reservation.Record.Id, ct);
|
||||
return BadRequest(ex.Message);
|
||||
}
|
||||
catch (AiUnavailableException ex)
|
||||
|
||||
Reference in New Issue
Block a user