Pattern for a bring-your-own-key deployment: your app hosts an admin page where each tenant picks a provider and saves a key;
the editor calls your backend, which reads the key from your secrets store and talks to the provider. In ASP.NET Core the idiomatic
production path is to register your own IRichTextBoxAiResolver - the settings form on this page is the
tenant-facing piece that feeds it.
See also: Structured content demo for JSON / Markdown round-trip.
localStorage for convenience, and keeps the
entered key in memory only for this page's lifetime. Never put a real API key in browser storage in production -
it's a credential leak. In a real deployment, your admin form posts the key to your server over HTTPS and stores it in a secrets
manager (AWS Secrets Manager, Azure Key Vault, HashiCorp Vault, or an encrypted DB column).
/api/ai. Only the session cookie identifies the caller.
For a real deployment, skip the client-side resolver swap and register a server-side resolver in DI. The tenant's API key never reaches the browser - the resolver reads it from your secrets store, calls the provider, and returns the response.
// Program.cs
builder.Services.AddRichTextBox();
builder.Services.AddSingleton<ITenantAIConfigStore, TenantAIConfigStore>();
builder.Services.AddSingleton<IRichTextBoxAiResolver, ByokAiResolver>();
public sealed class ByokAiResolver : IRichTextBoxAiResolver
{
private readonly ITenantAIConfigStore _secrets;
private readonly IHttpContextAccessor _http;
public ByokAiResolver(ITenantAIConfigStore secrets, IHttpContextAccessor http)
{
_secrets = secrets;
_http = http;
}
public async ValueTask<RichTextBoxAiResponse> ResolveAsync(
RichTextBoxAiRequest request,
CancellationToken cancellationToken = default)
{
// Resolve the tenant from the current user/session.
var tenantId = _http.HttpContext?.User?.FindFirst("tenant_id")?.Value;
var cfg = await _secrets.GetAsync(tenantId!, cancellationToken);
if (cfg is null)
{
return RichTextBoxAiResponseBuilder.FromOperations(
"No AI provider configured for this tenant.",
RichTextBoxAiResponseBuilder.PreviewSuggestion(request.DocumentText,
"Ask an admin to configure a provider in the tenant settings."));
}
// Call the provider with the tenant's key (never echoed back to the browser).
var reply = await _providerRouter.CallAsync(cfg.Provider, cfg.Model, cfg.ApiKey,
request.Mode, request.Source, request.Language, cancellationToken);
return RichTextBoxAiResponseBuilder.FromOperations(
reply.Explanation,
RichTextBoxAiResponseBuilder.PreviewSuggestion(reply.Text, reply.Explanation));
}
}
If you want the editor to hit your own endpoint instead of registering a resolver, swap the client-side
resolver with aiToolkit.setResolver() and stand up an endpoint like this:
// Program.cs - add next to app.MapRichTextBoxUploads();
app.MapPost("/api/ai", async (HttpContext ctx, AIRequest body, ITenantAIConfigStore secrets) =>
{
var tenantId = ctx.User.FindFirst("tenant_id")?.Value;
var cfg = await secrets.GetAsync(tenantId!);
if (cfg is null) return Results.BadRequest(new { error = "No provider configured." });
var reply = await ProviderRouter.CallAsync(cfg.Provider, cfg.Model, cfg.ApiKey,
body.Mode, body.Text, body.Language);
return Results.Json(new { result = reply.Text, explanation = reply.Explanation });
});
Keys belong in your secrets manager, not in browser storage. The editor UI (Ask AI dialog, Chat panel,
Review drawer) is provider-agnostic - whatever your IRichTextBoxAiResolver returns
drives every surface. The admin settings form above only lets tenants choose which resolver
config to activate; the secret material itself never crosses the client boundary in a real deployment.