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
+21 -5
View File
@@ -31,7 +31,8 @@ public sealed class AiOperationAdmission(
UserManager<ApplicationUser> users,
AiPrivacyPolicy privacy,
IConfiguration configuration,
TimeProvider timeProvider)
TimeProvider timeProvider,
AiUsageMeter usage)
{
// ponytail: process-local gate is sufficient for the current single-backend deployment;
// replace with a database capacity reservation before running multiple backend replicas.
@@ -48,7 +49,8 @@ public sealed class AiOperationAdmission(
var userId = currentUser.UserId;
var user = string.IsNullOrWhiteSpace(userId) ? null : await users.FindByIdAsync(userId);
if (user is null) throw new AiOperationAdmissionException("unauthorized", "Authentication is required.", StatusCodes.Status401Unauthorized);
if (!AccountPlans.ForRoles(await users.GetRolesAsync(user)).Ai)
var entitlements = AccountPlans.ForRoles(await users.GetRolesAsync(user));
if (!entitlements.Ai)
throw new AiOperationAdmissionException(ProEntitlement.RequiredCode, "This AI feature requires Pro.", StatusCodes.Status403Forbidden);
if (!user.AiEnabled)
throw new AiOperationAdmissionException(ProEntitlement.DisabledCode, "AI is disabled in your privacy settings.", StatusCodes.Status403Forbidden);
@@ -68,6 +70,15 @@ public sealed class AiOperationAdmission(
throw new AiOperationAdmissionException("ai_queue_full", "AI processing is busy. Try again shortly.", StatusCodes.Status429TooManyRequests, 15);
var policy = await privacy.EvaluateAsync(user.Id, cancellationToken);
var usageReservation = AiUsageMeter.ReservationFor(taskType);
try
{
await usage.EnsureCanReserveAsync(user.Id, entitlements, 1, usageReservation.EstimatedTokens, cancellationToken);
}
catch (AiUsageLimitException ex)
{
throw new AiOperationAdmissionException(ex.Code, ex.Message, StatusCodes.Status429TooManyRequests);
}
var deadlineMinutes = Math.Clamp(configuration.GetValue("AiQueue:DeadlineMinutes", 15), 1, 120);
var created = await operations.CreateAsync(new CreateUserOperation(
taskType,
@@ -78,7 +89,9 @@ public sealed class AiOperationAdmission(
subjectId,
priority,
Math.Clamp(configuration.GetValue("AiQueue:MaxAttempts", 3), 1, 10),
timeProvider.GetUtcNow().UtcDateTime.AddMinutes(deadlineMinutes)), cancellationToken);
timeProvider.GetUtcNow().UtcDateTime.AddMinutes(deadlineMinutes),
usageReservation.InputCharacters,
usageReservation.EstimatedTokens), cancellationToken);
return Result(created.Operation, created.Created);
}
finally
@@ -96,7 +109,9 @@ public sealed record AiOperationExecutionResult(
string? ResultReference,
string? Provider = null,
string? Model = null,
string? RouteReason = null);
string? RouteReason = null,
int? UsageInputCharacters = null,
int? UsageOutputCharacters = null);
public sealed class AiOperationExecutionScope
{
@@ -180,7 +195,8 @@ public sealed class AiOperationWorker(
await store.AcknowledgeCancellationAsync(lease.OperationId, lease.LeaseToken, stoppingToken);
else
await store.CompleteAsync(lease.OperationId, lease.LeaseToken, result.ResultReference,
result.Provider, result.Model, result.RouteReason, stoppingToken);
result.Provider, result.Model, result.RouteReason,
result.UsageInputCharacters, result.UsageOutputCharacters, stoppingToken);
}
catch (AiOperationFailure failure)
{