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