feat(ai): centralize durable usage
CI and Deploy / test (pull_request) Successful in 5m19s
CI and Deploy / deploy (pull_request) Has been skipped

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:
cesnimda
2026-08-15 20:03:06 +02:00
parent dbff0f8d49
commit 134aac7bcf
28 changed files with 3539 additions and 83 deletions
@@ -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)