fix(ai): meter synchronous generations
This commit is contained in:
@@ -0,0 +1,25 @@
|
||||
namespace JobTrackerApi.Services;
|
||||
|
||||
public sealed class AiUsageExecutionScope
|
||||
{
|
||||
private readonly AsyncLocal<int> _suppressionDepth = new();
|
||||
|
||||
public bool IsSuppressed => _suppressionDepth.Value > 0;
|
||||
|
||||
public IDisposable Suppress()
|
||||
{
|
||||
_suppressionDepth.Value++;
|
||||
return new Restore(this);
|
||||
}
|
||||
|
||||
private sealed class Restore(AiUsageExecutionScope owner) : IDisposable
|
||||
{
|
||||
private AiUsageExecutionScope? _owner = owner;
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
var current = Interlocked.Exchange(ref _owner, null);
|
||||
if (current is not null) current._suppressionDepth.Value--;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
using Microsoft.AspNetCore.Diagnostics;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
namespace JobTrackerApi.Services;
|
||||
|
||||
public sealed class AiUsageLimitExceptionHandler : IExceptionHandler
|
||||
{
|
||||
public async ValueTask<bool> TryHandleAsync(HttpContext httpContext, Exception exception, CancellationToken cancellationToken)
|
||||
{
|
||||
if (exception is not AiUsageLimitException limit) return false;
|
||||
|
||||
httpContext.Response.StatusCode = StatusCodes.Status429TooManyRequests;
|
||||
await httpContext.Response.WriteAsJsonAsync(new ProblemDetails
|
||||
{
|
||||
Status = StatusCodes.Status429TooManyRequests,
|
||||
Title = "AI usage limit reached",
|
||||
Detail = limit.Message,
|
||||
Extensions =
|
||||
{
|
||||
["code"] = limit.Code,
|
||||
["traceId"] = httpContext.TraceIdentifier,
|
||||
},
|
||||
}, cancellationToken);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
using JobTrackerApi.Data;
|
||||
using JobTrackerApi.Models;
|
||||
using Microsoft.AspNetCore.Identity;
|
||||
|
||||
namespace JobTrackerApi.Services;
|
||||
|
||||
// The shared synchronous provider boundary. Durable operations and the AI workspace reserve
|
||||
// usage before reaching this layer, so their execution scopes explicitly bypass this decorator.
|
||||
public sealed class MeteredSummarizerService(
|
||||
SummarizerService inner,
|
||||
JobTrackerContext db,
|
||||
UserManager<ApplicationUser> users,
|
||||
AiUsageMeter usage,
|
||||
AiOperationExecutionScope operationScope,
|
||||
AiUsageExecutionScope usageScope) : ISummarizerService
|
||||
{
|
||||
public Task<string?> SummarizeAsync(string text, int maxLength = 150, int minLength = 30)
|
||||
=> MeterAsync("synchronous.summary", text, maxLength, ct => inner.SummarizeAsync(text, maxLength, minLength), CancellationToken.None);
|
||||
|
||||
public Task<string?> SummarizeSectionAsync(string instruction, string text, int maxLength = 180, int minLength = 40)
|
||||
=> MeterAsync("synchronous.rewrite", instruction + "\n\n" + text, maxLength,
|
||||
ct => inner.SummarizeSectionAsync(instruction, text, maxLength, minLength), CancellationToken.None);
|
||||
|
||||
public Task<AiGenerationResult?> GenerateSectionWithMetadataAsync(
|
||||
string instruction,
|
||||
string text,
|
||||
int maxLength = 180,
|
||||
int minLength = 40,
|
||||
CancellationToken cancellationToken = default)
|
||||
=> MeterAsync("synchronous.generate", instruction + "\n\n" + text, maxLength,
|
||||
ct => inner.GenerateSectionWithMetadataAsync(instruction, text, maxLength, minLength, ct), cancellationToken);
|
||||
|
||||
public Task<AiTextExtractionResult?> ExtractTextAsync(
|
||||
Stream stream,
|
||||
string fileName,
|
||||
string? contentType = null,
|
||||
CancellationToken cancellationToken = default)
|
||||
=> inner.ExtractTextAsync(stream, fileName, contentType, cancellationToken);
|
||||
|
||||
public Task RunProbeAsync(CancellationToken cancellationToken = default)
|
||||
=> inner.RunProbeAsync(cancellationToken);
|
||||
|
||||
public Task<AiServiceMetrics> GetMetricsAsync(CancellationToken cancellationToken = default)
|
||||
=> inner.GetMetricsAsync(cancellationToken);
|
||||
|
||||
private async Task<T?> MeterAsync<T>(
|
||||
string taskType,
|
||||
string input,
|
||||
int maximumOutputCharacters,
|
||||
Func<CancellationToken, Task<T?>> generate,
|
||||
CancellationToken cancellationToken)
|
||||
where T : class
|
||||
{
|
||||
if (usageScope.IsSuppressed || operationScope.Current is not null || string.IsNullOrWhiteSpace(db.CurrentUserId))
|
||||
return await generate(cancellationToken);
|
||||
|
||||
var ownerUserId = db.CurrentUserId;
|
||||
var user = await users.FindByIdAsync(ownerUserId);
|
||||
var entitlements = user is null ? AccountPlans.ForRoles(null) : AccountPlans.ForRoles(await users.GetRolesAsync(user));
|
||||
if (user is null || !user.AiEnabled || !entitlements.Ai)
|
||||
throw new AiUsageLimitException("ai_not_available", "AI features require an active Pro account with AI enabled.");
|
||||
|
||||
var boundedInputCharacters = Math.Min(input.Length, 20_000);
|
||||
var boundedOutputCharacters = Math.Clamp(maximumOutputCharacters, 0, 4_096);
|
||||
var estimatedTokens = Math.Max(1, (boundedInputCharacters + boundedOutputCharacters + 3) / 4);
|
||||
var reservation = await usage.ReserveAsync(
|
||||
ownerUserId,
|
||||
entitlements,
|
||||
"synchronous",
|
||||
Guid.NewGuid().ToString("D"),
|
||||
taskType,
|
||||
boundedInputCharacters,
|
||||
estimatedTokens,
|
||||
cancellationToken);
|
||||
|
||||
var result = await generate(cancellationToken);
|
||||
switch (result)
|
||||
{
|
||||
case string text:
|
||||
await usage.FinalizeAsync(reservation.Record.Id, boundedInputCharacters, text.Length, cancellationToken);
|
||||
break;
|
||||
case AiGenerationResult generation:
|
||||
await usage.FinalizeAsync(reservation.Record.Id, boundedInputCharacters, generation.Text.Length, cancellationToken);
|
||||
break;
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user