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
+32 -1
View File
@@ -64,6 +64,9 @@ public sealed class AiOperationQueueTests
var operation = await db.UserOperations.IgnoreQueryFilters().AsNoTracking().SingleAsync();
Assert.Equal(OperationStatuses.Succeeded, operation.Status);
Assert.Equal("synthetic-result", operation.ResultReference);
Assert.Equal("ollama", operation.Provider);
Assert.Equal("qwen-test", operation.Model);
Assert.Equal("local_primary", operation.ProgressStage);
Assert.Equal("pro-1", fixture.Handler.OwnerUserId);
Assert.Equal("local_only", fixture.Handler.PrivacyPolicy);
Assert.Equal("operation_succeeded", (await db.UserNotifications.IgnoreQueryFilters().AsNoTracking().SingleAsync()).Kind);
@@ -95,19 +98,46 @@ public sealed class AiOperationQueueTests
Assert.Equal("entitlement_changed", failed.FailureCategory);
}
[Fact]
public async Task Worker_records_provider_metadata_for_generation_failures()
{
await using var fixture = await Fixture.CreateAsync();
await fixture.SeedUserAsync("pro-1", pro: true);
await fixture.EnqueueAsync("pro-1", "provider-failure");
fixture.Handler.GenerationFailure = new AiGenerationException(
"provider_unavailable",
"AI provider unavailable.",
retryable: true,
provider: "gemini",
model: "gemini-test",
routeReason: "external_fallback");
Assert.True(await fixture.Provider.GetRequiredService<AiOperationWorker>().RunOnceAsync(default));
await using var scope = fixture.Provider.CreateAsyncScope();
var row = await scope.ServiceProvider.GetRequiredService<JobTrackerContext>()
.UserOperations.IgnoreQueryFilters().AsNoTracking().SingleAsync();
Assert.Equal(OperationStatuses.WaitingForRetry, row.Status);
Assert.Equal("gemini", row.Provider);
Assert.Equal("gemini-test", row.Model);
Assert.Equal("external_fallback", row.ProgressStage);
}
private sealed class SyntheticHandler : IAiOperationHandler
{
public string TaskType => "synthetic.ai";
public string? OwnerUserId { get; private set; }
public string? PrivacyPolicy { get; private set; }
public AiOperationFailure? Failure { get; set; }
public AiGenerationException? GenerationFailure { get; set; }
public Task<AiOperationExecutionResult> ExecuteAsync(AiOperationExecutionContext context, IServiceProvider services, CancellationToken cancellationToken)
{
if (Failure is not null) throw Failure;
if (GenerationFailure is not null) throw GenerationFailure;
OwnerUserId = services.GetRequiredService<ICurrentUserService>().UserId;
PrivacyPolicy = context.EffectivePrivacyPolicy;
return Task.FromResult(new AiOperationExecutionResult("synthetic-result"));
return Task.FromResult(new AiOperationExecutionResult("synthetic-result", "ollama", "qwen-test", "local_primary"));
}
}
@@ -147,6 +177,7 @@ public sealed class AiOperationQueueTests
services.AddSingleton(TimeProvider.System);
services.AddScoped<UserOperationStore>();
services.AddSingleton<AiPrivacyPolicy>();
services.AddSingleton<AiOperationExecutionScope>();
services.AddScoped<AiOperationAdmission>();
services.AddSingleton<IAiOperationHandler>(handler);
services.AddSingleton<AiOperationWorker>();