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