Bring your own key (BYOK)
Multi-tenant SaaS apps can charge AI traffic to each tenant’s own provider account — OpenAI, Anthropic, Azure OpenAI — instead of routing everything through a shared host key. The built-inIAiKeyVault abstraction is the seam.
How it works
On every AI request, the resolver consults IAiKeyVault.GetKeyAsync with anAiKeyRequestContext that carries the active HttpContext, provider name, and AI mode. Your vault implementation extracts the tenant id (typically from a claim) and returns the right AiKeyMaterial — an ApiKey plus an optional KeyIdfor audit attribution.
The default OptionsBackedAiKeyVault returns Empty, so resolvers fall back toAiResolverOptions.ApiKey. Non-BYOK customers see zero behaviour change.
The interface
public interface IAiKeyVault{Task<AiKeyMaterial> GetKeyAsync(AiKeyRequestContext context, CancellationToken ct = default);}public sealed class AiKeyMaterial{public string ApiKey { get; init; } // the secret (never logged)public string? KeyId { get; init; } // non-sensitive id for audit// Azure-only per-tenant overrides:public string? AzureEndpoint { get; init; }public string? AzureDeploymentName { get; init; }public string? AzureApiVersion { get; init; }}
Wiring up the in-memory reference vault
InMemoryAiKeyVault implements both IAiKeyVault andIAiKeyVaultAdmin. State vanishes on restart; not for production.
builder.Services.AddRichTextBox(); builder.Services.AddSingleton<InMemoryAiKeyVault>(); builder.Services.AddSingleton<IAiKeyVault>(sp => sp.GetRequiredService<InMemoryAiKeyVault>()); builder.Services.AddSingleton<IAiKeyVaultAdmin>(sp => sp.GetRequiredService<InMemoryAiKeyVault>()); builder.Services.AddRichTextBoxOpenAiResolver(opts =>{// No baked-in ApiKey — the vault provides per-tenant keys.opts.AllowEmptyApiKey = true; opts.Model = "gpt-4o-mini";});// Admin endpoints behind your auth middleware (off by default).app.MapRichTextBoxUploads(); app.MapRichTextBoxAiKeyVaultAdmin().RequireAuthorization("AdminOnly");
Admin REST endpoints
| Route | Body / Query | Returns |
|---|---|---|
POST /richtextbox/ai/vault/keys | { tenantId, provider, apiKey, keyId?, azureEndpoint?, azureDeploymentName?, azureApiVersion? } | 200 + entry metadata (no secret) |
GET /richtextbox/ai/vault/keys?tenant=<id> | Optional filter | 200 + array of entries |
DELETE /richtextbox/ai/vault/keys/{keyId} | — | 204 / 404 |
Browser-side: typed admin client
The npm package ships a typed TS client at @richscripts2/richtexteditor/admin— tenant-settings UIs (where customers paste their keys) call into it instead of hand-rolling fetch.
import { createAdminClient } from "@richscripts2/richtexteditor/admin";const admin = createAdminClient({baseUrl: "/richtextbox/ai/vault/keys", fetch: (url, init) => fetch(url, {...init, headers: { ...init?.headers, "Authorization": "Bearer " + adminJwt },}),});await admin.upsert({ tenantId: "acme", provider: "OpenAI", apiKey: "sk-..." });const keys = await admin.list("acme");await admin.delete(keys[0].keyId);
app.MapRichTextBoxAiKeyVaultAdmin() explicitly and must wrap the route group in their own auth middleware. The library does not assume an auth model.Audit logging
Vault hits and misses emit structured EventIds under category RichTextBox.Audit:
| EventId | Name | Fires when |
|---|---|---|
| 8204 | AiKeyVaultMiss | Vault returned Empty + no fallback ApiKey → client sees friendly “AI not configured”. |
| 8205 | AiKeyVaultHit | Vault returned a key. KeyId is logged; the secret never is. |
KeyId on hit. Custom vault implementations should follow the same discipline.Per-call cost attribution
BYOK pairs naturally with the per-call cost ledger. ImplementIRichTextBoxAiCostSink and you’ll get an AiUsageRecordper AI call — provider, model, mode, input/output/total tokens, latency, and the KeyIdfrom the vault hit. Forward to your billing system for chargeback:
public sealed class BillingAiCostSink : IRichTextBoxAiCostSink{public Task RecordAsync(AiUsageRecord record, CancellationToken ct = default){return _billing.EnqueueAsync(new{keyId = record.KeyId, model = record.Model, tokens = record.TotalTokens, mode = record.Mode, ts = record.TimestampUtc,}, ct);}}
Production: Redis-backed vault
For multi-instance deployments where the in-memory reference doesn’t fit and Azure Key Vault is overkill, a Redis-backed implementation gives you persistence + cross-instance consistency in ~50 lines. Afterdotnet add package StackExchange.Redis:
public sealed class RedisAiKeyVault : IAiKeyVault, IAiKeyVaultAdmin{private readonly IConnectionMultiplexer _redis;private readonly IHttpContextAccessor _http;private const string KeyPrefix = "rtb:vault:";public RedisAiKeyVault(IConnectionMultiplexer redis, IHttpContextAccessor http){_redis = redis; _http = http;}public async Task<AiKeyMaterial> GetKeyAsync(AiKeyRequestContext ctx, CancellationToken ct = default){var tenant = _http.HttpContext?.User.FindFirstValue("tenant_id");if (string.IsNullOrEmpty(tenant)) return AiKeyMaterial.Empty;var raw = await _redis.GetDatabase().StringGetAsync(KeyPrefix + tenant + ":" + ctx.Provider);if (raw.IsNullOrEmpty) return AiKeyMaterial.Empty;return JsonSerializer.Deserialize<AiKeyMaterial>(raw!) ?? AiKeyMaterial.Empty;}// UpsertAsync / ListAsync / DeleteAsync omitted for brevity. See// richtextbox.com/ByokVault for the full implementation.}
IDataProtectionProvider on top — encrypt before StringSetAsync, decrypt after StringGetAsync. The shipped FileBackedAiKeyVault is a good reference for the encrypt/decrypt envelope.Production: Azure Key Vault
For HIPAA / PCI / SOC2 compliance, persist keys in Azure Key Vault and let the platform handle KMS-backed encryption, access policies, audit logging, and rotation. Add SDK packagesAzure.Security.KeyVault.Secrets + Azure.Identity:
public sealed class AzureKeyVaultAiKeyVault : IAiKeyVault, IAiKeyVaultAdmin{private readonly SecretClient _client;private readonly IHttpContextAccessor _http;public AzureKeyVaultAiKeyVault(SecretClient client, IHttpContextAccessor http){_client = client; _http = http;}public async Task<AiKeyMaterial> GetKeyAsync(AiKeyRequestContext ctx, CancellationToken ct = default){var tenantId = _http.HttpContext?.User.FindFirstValue("tenant_id");if (string.IsNullOrEmpty(tenantId)) return AiKeyMaterial.Empty;try{var secret = await _client.GetSecretAsync($"rtb-{ctx.Provider.ToLowerInvariant()}-{tenantId}", cancellationToken: ct);return new AiKeyMaterial{ApiKey = secret.Value.Value, KeyId = secret.Value.Properties.Version,};}catch (RequestFailedException ex) when (ex.Status == 404){return AiKeyMaterial.Empty;}}// UpsertAsync / ListAsync / DeleteAsync — see richtextbox.com/ByokVault.}builder.Services.AddSingleton(_ => new SecretClient(new Uri(builder.Configuration["AzureKeyVault:Uri"]!),new DefaultAzureCredential()));
IMemoryCache with a short TTL (~60 s) on top so a key rotation propagates within a minute or two while not paying Key Vault on every call.Cost-ledger batching
For high-traffic tenants, the per-call IRichTextBoxAiCostSink can paper-cut a downstream billing API. Wrap it with BufferingAiCostSink— records accumulate in a bounded queue and a background drainer flushes them in batches:
builder.Services.AddSingleton<IRichTextBoxAiCostSink>(sp =>new BufferingAiCostSink( inner: new MyBillingApiCostSink(sp.GetRequiredService<HttpClient>()), flushInterval: TimeSpan.FromSeconds(30), maxBatchSize: 500, logger: sp.GetService<ILogger<BufferingAiCostSink>>()));
RecordAsyncnever blocks — queue overflow drops records and bumps an observableDroppedCountmetric. Inner-sink failures are caught per record so one bad write doesn’t poison the batch. DisposeAsync drains pending records before returning.
Health probe for the AI dependency
/richtextbox/health reports license + service-registration status by default. To also assert the configured AI provider is actually reachable, register a resolver implementingIRichTextBoxAiResolverProbe and enable the probe:
builder.Services.AddRichTextBox(opts =>{opts.AiResolverHealthProbeEnabled = true; opts.AiResolverHealthProbeTimeout = TimeSpan.FromSeconds(3);});
The endpoint reports 503 with { status: "ai_unreachable", aiResolverProbe: { ... } }on probe failure. Built-in resolvers don’t implement the probe by default — spending tokens on health checks is an explicit ops decision — but a custom resolver can wrap one (typically by hitting a cheap reachability endpoint like OpenAI’s GET /v1/models).
Output filtering: PII redaction, link rewriting, content blocking
IRichTextBoxAiResponseFilter runs after the resolver but before the response goes to the wire. Customers redact PII / mask secrets / rewrite links / block policy violations without re-implementing every resolver. The reference RegexAiResponseFilter covers ~80% of practical PII redaction:
var emailMask = new Regex(@"[\w.+-]+@[\w-]+\.[\w.-]+", RegexOptions.IgnoreCase);var ssnMask = new Regex(@"\b\d{3}-\d{2}-\d{4}\b"); builder.Services.AddSingleton<IRichTextBoxAiResponseFilter>(new RegexAiResponseFilter( (emailMask, "[email-redacted]"), (ssnMask, "[ssn-redacted]")));
Filters compose linearly — each receives the previous filter’s output. Throwing filters are caught and logged but don’t take AI traffic down. EventId 8206 AiResponseFilterMatchedfires when a filter mutated the response (the filter type is logged, never the body).
Companion docs
See AI providers & streaming for the resolver wiring, and the Configurationdoc for the full options surface.