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:
@@ -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)
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user