feat(ai): enforce local-first routing

Keep external providers behind server consent, task, and prompt-cost gates while persisting actual provider provenance.
This commit is contained in:
cesnimda
2026-08-09 12:30:11 +02:00
parent c3f4a57195
commit 5eb9b3cb96
29 changed files with 967 additions and 145 deletions
+37 -4
View File
@@ -92,7 +92,31 @@ public sealed class AiOperationAdmission(
}
public sealed record AiOperationExecutionContext(UserOperationLease Lease, string EffectivePrivacyPolicy);
public sealed record AiOperationExecutionResult(string? ResultReference);
public sealed record AiOperationExecutionResult(
string? ResultReference,
string? Provider = null,
string? Model = null,
string? RouteReason = null);
public sealed class AiOperationExecutionScope
{
private readonly AsyncLocal<AiOperationExecutionContext?> _current = new();
public AiOperationExecutionContext? Current => _current.Value;
public IDisposable Use(AiOperationExecutionContext context)
{
var previous = _current.Value;
_current.Value = context;
return new Restore(() => _current.Value = previous);
}
private sealed class Restore(Action restore) : IDisposable
{
private Action? _restore = restore;
public void Dispose() => Interlocked.Exchange(ref _restore, null)?.Invoke();
}
}
public interface IAiOperationHandler
{
@@ -110,6 +134,7 @@ public sealed class AiOperationWorker(
IServiceScopeFactory scopes,
IEnumerable<IAiOperationHandler> registeredHandlers,
AiPrivacyPolicy privacy,
AiOperationExecutionScope executionScope,
IConfiguration configuration)
{
private readonly IReadOnlyDictionary<string, IAiOperationHandler> _handlers = registeredHandlers
@@ -147,19 +172,27 @@ public sealed class AiOperationWorker(
try
{
var result = await _handlers[lease.TaskType].ExecuteAsync(
new AiOperationExecutionContext(lease, effectivePrivacy), ownerScope.ServiceProvider, execution.Token);
var context = new AiOperationExecutionContext(lease, effectivePrivacy);
using var routing = executionScope.Use(context);
var result = await _handlers[lease.TaskType].ExecuteAsync(context, ownerScope.ServiceProvider, execution.Token);
var row = await store.GetAsync(lease.OperationId, stoppingToken);
if (row?.CancellationRequestedAtUtc is not null)
await store.AcknowledgeCancellationAsync(lease.OperationId, lease.LeaseToken, stoppingToken);
else
await store.CompleteAsync(lease.OperationId, lease.LeaseToken, result.ResultReference, stoppingToken);
await store.CompleteAsync(lease.OperationId, lease.LeaseToken, result.ResultReference,
result.Provider, result.Model, result.RouteReason, stoppingToken);
}
catch (AiOperationFailure failure)
{
await store.FailAsync(lease.OperationId, lease.LeaseToken, failure.Retryable, failure.Category,
failure.Message, RetryDelay(lease.AttemptCount), stoppingToken);
}
catch (AiGenerationException failure)
{
await store.FailAsync(lease.OperationId, lease.LeaseToken, failure.Retryable, failure.Category,
failure.Message, RetryDelay(lease.AttemptCount), failure.Provider, failure.Model,
failure.RouteReason, stoppingToken);
}
catch (OperationCanceledException) when (!stoppingToken.IsCancellationRequested)
{
var row = await store.GetAsync(lease.OperationId, stoppingToken);