Running in production
Defaults are tuned for a typical SaaS customer. Below are the knobs you'll touch when traffic shapes
change — rate limiting, retry policy, audit logging, dependency reachability probes,
IHealthCheck integration, idempotency, prompt-template overrides, and the BYOK admin surface.
Rate limiting AI calls
Both POST /richtextbox/ai and POST /richtextbox/ai/stream share a per-IP
token bucket so a single runaway tab can't burn through your provider quota in seconds. Defaults: 30
calls per minute per IP.
builder.Services.AddRichTextBox(opts => { opts.AiRateLimit = 30; // calls per window opts.AiRateLimitWindow = TimeSpan.FromMinutes(1); // window length });
Set AiRateLimit = 0 to disable, or swap in a Redis-backed limiter:
builder.Services.AddSingleton<IRichTextBoxAiRateLimiter, RedisAiRateLimiter>(); builder.Services.AddRichTextBox();
HTTP retry on transient provider failures
OpenAI / Anthropic / Azure OpenAI return 429 and 503 under normal load. The
built-in resolvers retry these (plus 502 / 504 + connection drops) with exponential backoff and full
jitter, capped at 30 seconds. The provider's Retry-After header is honoured.
services.AddRichTextBoxOpenAiResolver(opts => { opts.ApiKey = builder.Configuration["OpenAI:ApiKey"]; opts.MaxRetryAttempts = 2; // 0 to disable opts.RetryBaseDelay = TimeSpan.FromMilliseconds(500); });
Idempotency keys
Every POST /richtextbox/ai, /ai/stream, and /upload respects
Idempotency-Key. Duplicate retries replay the cached body verbatim — no double charging,
no orphan files. Streaming responses replay the entire SSE byte stream byte-for-byte.
builder.Services.AddRichTextBox(opts => { opts.IdempotencyTtl = TimeSpan.FromHours(1); opts.IdempotencyPruneInterval = TimeSpan.FromMinutes(5); });
Multi-instance hosts: register a Redis-backed IIdempotencyStore instead of the default in-memory one. Native TTL means you can disable the periodic pruner.
Security audit logging
Every security-relevant rejection emits a structured LogWarning under category
RichTextBox.Audit with a stable EventId:
| EventId | Name | Fires when |
|---|---|---|
| 8001 | LicenseInvalid | An endpoint refused a request because the .lic file was missing or invalid. |
| 8101 | UploadMagicByteMismatch | Magic bytes don't match the declared extension. |
| 8102 | UploadRejectedByValidator | An IUploadValidator returned Reject. |
| 8103 | UploadInvalidExtension | Upload rejected for being outside the allow-list. |
| 8104 | UploadOversize | Exceeded MaxUploadBytes. |
| 8201 | AiRequestOversize | AI body exceeded MaxRequestBytes. |
| 8202 | AiRateLimitExceeded | Caller exceeded the per-IP AI rate limit. |
| 8203 | AiResolverException | The AI resolver threw; logged with correlation id. |
| 8204 | AiKeyVaultMiss | BYOK vault returned no key. |
| 8205 | AiKeyVaultHit | BYOK vault returned a key (KeyId logged; secret never). |
| 8206 | AiResponseFilterMatched | An IRichTextBoxAiResponseFilter mutated the response. |
Routing audit events to a separate stream
IRichTextBoxAuditSink lets compliance-regulated workloads (HIPAA / PCI / SOC2) route just
audit events to a write-once destination — Splunk HEC, S3 with object-lock, an audit-only DB.
Ships with JsonLinesAuditSink for offline / single-instance use:
builder.Services.AddRichTextBox(); builder.Services.AddJsonLinesAuditSink("/var/log/myapp/rtb-audit.jsonl");
Channel + background drainer; RecordAsync
never blocks. Queue overflow drops events with a DroppedCount metric. Multiple sinks register
naturally — pair JsonLines with a real-time Splunk sink for free.
Dependency reachability probes
Optional companion interfaces let any RichTextBox dependency report whether its backing service is
reachable. The /richtextbox/health endpoint surfaces the result alongside license status:
| Interface | Built-in implementations | Probe target |
|---|---|---|
IRichTextBoxAiResolverProbe |
OpenAI, Azure OpenAI (free); AnthropicResolverWithProbe wrapper (low-token POST) |
GET /v1/models / GET /openai/deployments / POST /v1/messages with max_tokens=1 |
IIdempotencyStoreProbe |
Custom Redis / SQL store (you implement) | PING / SELECT 1 |
IRichTextBoxUploadStoreProbe |
Custom S3 / Azure Blob / Cloudinary store | HEAD bucket / list-objects --max-items 1 |
builder.Services.AddRichTextBox(opts => { opts.AiResolverHealthProbeEnabled = true; opts.AiResolverHealthProbeTimeout = TimeSpan.FromSeconds(3); opts.AiResolverHealthProbeCacheTtl = TimeSpan.FromSeconds(30); opts.IdempotencyStoreHealthProbeEnabled = true; opts.UploadStoreHealthProbeEnabled = true; });
Cache TTL on each probe defends against load-balancer hammering — a balancer hitting /health
every 5 seconds would otherwise produce 12 probes per minute even when probes cost real money.
Standard IHealthCheck integration
For hosts using Microsoft.Extensions.Diagnostics.HealthChecks, register the adapter and
RichTextBox status appears alongside your other checks — no parallel monitoring path:
builder.Services.AddRichTextBox(); builder.Services.AddHealthChecks() .AddRichTextBox(name: "richtextbox", failureStatus: HealthStatus.Unhealthy, tags: new[] { "ready" }); app.MapHealthChecks("/health");
Returns Healthy when license + all gated probes pass; Unhealthy with a
description identifying the failed dependency. HealthCheckResult.Data mirrors the JSON
payload of /richtextbox/health.
BYOK — per-tenant API keys
Multi-tenant SaaS hosts charge AI traffic to each tenant's own provider account via
IAiKeyVault + IAiKeyVaultAdmin. The package ships an in-memory reference
implementation; production deployments back onto Azure Key Vault, Redis, or a real SQL store.
See the dedicated BYOK key vault page for the
full contract, admin REST endpoints, and reference implementations (Redis, Azure Key Vault, encrypted-on-disk
FileBackedAiKeyVault).
Per-call cost ledger
IRichTextBoxAiCostSink emits one AiUsageRecord per AI call — provider,
model, mode, tokens (input / output / total), latency, status, KeyId. Forward to your billing system for
tenant chargeback:
builder.Services.AddSingleton<IRichTextBoxAiCostSink, MyBillingApiCostSink>();
For high-traffic tenants, wrap with the buffering sink so the inner sees one batched flush per interval
instead of one HTTP/SQL call per AI request. If your inner sink also implements
IRichTextBoxAiCostBatchSink, the wrapper routes through RecordBatchAsync —
one INSERT instead of N.
builder.Services.AddBufferingAiCostSink<MyBillingApiCostSink>( flushInterval: TimeSpan.FromSeconds(30), maxBatchSize: 500);
Output filtering — PII redaction, content blocking
IRichTextBoxAiResponseFilter runs after the resolver but before the response goes to the
wire. The reference RegexAiResponseFilter covers ~80% of practical PII redaction:
var emailMask = new Regex(@"[\w.+-]+@[\w-]+\.[\w.-]+"); var ssnMask = new Regex(@"\b\d{3}-\d{2}-\d{4}\b"); builder.Services.AddSingleton<IRichTextBoxAiResponseFilter>( new RegexAiResponseFilter( (emailMask, "[email-redacted]"), (ssnMask, "[ssn-redacted]")));
For streaming responses, the framework buffers tokens server-side when filters are registered
(BufferStreamingForFilters = true default) so a credit-card regex spanning multiple deltas
actually matches.
Prompt-template overrides
IRichTextBoxPromptTemplateProvider lets you override the per-mode system prompts the
built-in resolvers send. Use the PromptTemplateProviderBase decorator base class to
override only the method(s) you need:
public sealed class FormalTonePromptProvider : PromptTemplateProviderBase { public override string BuildSystemPrompt(RichTextBoxAiRequest request, string? operatorSuffix) => base.BuildSystemPrompt(request, operatorSuffix) + "\n\nAlways reply in formal British English; avoid contractions."; }
For one-off mode tweaks without a full provider, use the dictionary:
services.AddRichTextBoxOpenAiResolver(opts => { opts.ApiKey = "sk-..."; opts.SystemPromptOverridesByMode["proofread"] = "You are a hyper-pedantic copy-editor for legal documents."; });
Prompt-injection screening
IRichTextBoxAiPolicy is the gate the AI endpoints consult before invoking the resolver. The
built-in DefaultPromptInjectionPolicy rejects off-the-shelf jailbreak templates ("ignore
previous instructions", "you are now DAN", etc.). Layer additional policies for PII redaction or
tenant-scoped mode allow-lists.
builder.Services.AddSingleton<IRichTextBoxAiPolicy, DefaultPromptInjectionPolicy>(); builder.Services.AddSingleton<IRichTextBoxAiPolicy, MyTenantPolicy>();
Distributed tracing (OpenTelemetry)
Every AI call, DOCX export, and upload emits a span through the
RichTextBox.AspNetCore ActivitySource. Subscribe in your tracer-provider
configuration:
builder.Services.AddOpenTelemetry().WithTracing(t => t .AddSource(RichTextBoxDiagnostics.SourceName) // "RichTextBox.AspNetCore" .AddAspNetCoreInstrumentation() .AddOtlpExporter());
Startup-time options validation
RichTextBoxOptionsValidator + AiResolverOptionsValidator<T> run at the
first IOptions.Value resolution — before the host accepts the first request. Bad
config (forgotten /, negative byte cap, heartbeat > timeout, missing API key, missing
Azure deployment) throws OptionsValidationException at startup so deployments fail loudly.
No wiring required. The validators register automatically via AddRichTextBox().
Companion docs
BYOK key vault · AI providers & streaming · Cloud upload providers · Accessibility