Skip to main content

Fluent per-detector config

opts.Configure<T>(c => ...) enables or disables a detector, clamps its severity output (Floor / Cap), or sets a semantic detector's similarity thresholds, without forking detector code. Pipeline-level concern — detectors stay unaware of configuration.

The knobs​

public sealed class DetectorConfiguration
{
public bool? Enabled { get; set; } // null: the detector's own default
public Severity? SeverityFloor { get; set; }
public Severity? SeverityCap { get; set; }
public SentinelAction? Action { get; set; }
public float? HighThreshold { get; set; } // semantic detectors only
public float? MediumThreshold { get; set; } // semantic detectors only
}

3.0 breaking change: Enabled is now bool?, where null means the detector's own default. Source that reads it as a bool needs updating, and an untouched configuration now reads null where 2.x read true, so a check on it must decide what null means; cfg.Enabled ?? detector.EnabledByDefault is what the pipeline uses. A binary built against 2.x that writes or reads Enabled, through the old set_Enabled(bool) or get_Enabled(), fails with MissingMethodException at runtime until it is recompiled against 3.0.

KnobEffect
Enabled = falsePipeline skips invoking this detector entirely. Zero CPU cost — disabled detectors never enter the _detectors array at construction time.
Enabled = trueRuns an opt-in detector. Left unset (null), each detector uses its IDetector.EnabledByDefault: on for every detector except the four Art. 5 compliance detectors, which are off until enabled.
HighThreshold / MediumThresholdSimilarity at or above which a semantic detector reports High / Medium, in place of its built-in value. Must be in (0, 1], with Medium not above High. Setting either on a detector that is not a SemanticDetectorBase is an ArgumentException at AddAISentinel.
SeverityFloor = HighClamp upward — any firing result emitted below High is rewritten to High. Clean results pass through unchanged (no fabricated findings).
SeverityCap = LowClamp downward — any firing result emitted above Low is rewritten to Low. Clean results unchanged.

Common patterns​

Disable a noisy detector​

opts.Configure<WrongLanguageDetector>(c => c.Enabled = false);
opts.Configure<RepetitionLoopDetector>(c => c.Enabled = false);

Promote a borderline detector​

// Anything JailbreakDetector flags should at least page on-call
opts.Configure<JailbreakDetector>(c => c.SeverityFloor = Severity.High);

Cap a noisy detector​

// PiiLeakage emits Critical for credit cards by default — cap to Medium so it logs but doesn't quarantine
opts.Configure<PiiLeakageDetector>(c => c.SeverityCap = Severity.Medium);

Cap and floor simultaneously​

// Always emit Medium-or-Low for this detector, never higher, never lower
opts.Configure<MyNoisyDetector>(c =>
{
c.SeverityFloor = Severity.Low;
c.SeverityCap = Severity.Medium;
});

Tune a semantic detector's thresholds​

Similarity scales differ between embedding models, so a semantic detector's built-in thresholds suit some models better than others. Override them per detector:

opts.Configure<JailbreakDetector>(c =>
{
c.HighThreshold = 0.80f;
c.MediumThreshold = 0.72f;
});

When a pipeline is built, an override is checked against the other, built-in threshold: lowering only HighThreshold below the detector's built-in Medium is an InvalidOperationException, so lower both. Each named pipeline applies its own thresholds even though the detector instance is shared: the pipeline passes them with each call.

How clamping works​

The DetectionPipeline runs every detector, then applies the clamp pass between dispatch and LLM escalation, and again after escalation so the LLM verdict stays within Floor and Cap:

[detect] → [clamp Floor/Cap] → [escalate ILlmEscalatingDetector hits] → [clamp Floor/Cap] → [aggregate]

The clamp uses the C# record with-expression so DetectorId and Reason are preserved verbatim:

result = result with { Severity = clamped };

Clean results bypass the clamp. A Severity.None from a detector that didn't fire stays None — Floor = High does not fabricate a finding.

Multiple Configure<T> calls merge by mutation​

If you call Configure<T> more than once for the same detector type, both calls apply against the same configuration instance. Later calls overwrite earlier ones on a per-property basis:

opts.Configure<JailbreakDetector>(c => c.SeverityFloor = Severity.Medium);
opts.Configure<JailbreakDetector>(c =>
{
// SeverityFloor is still Medium from the previous call
c.SeverityCap = Severity.Critical; // adds a cap
});
// Net effect: Floor=Medium, Cap=Critical, Enabled=null (the detector's default)

This is by design — lets you split configuration across helper methods, environment overlays, etc.

Where it lives​

Configure<T> lives on SentinelOptions (extension method). It works inside any AddAISentinel overload — default or named:

services.AddAISentinel(opts =>
{
// default pipeline tuning
opts.Configure<RepetitionLoopDetector>(c => c.SeverityCap = Severity.Low);
});

services.AddAISentinel("strict", opts =>
{
// strict pipeline tuning — independent of default
opts.Configure<JailbreakDetector>(c => c.SeverityFloor = Severity.High);
});

In a named-pipeline setup, each pipeline has its own DetectorConfiguration dictionary — Configure<T> on "strict" doesn't leak into "lenient".

What Configure<T> does NOT do​

  • It doesn't add detectors — the detector type T must already be registered (built-in via DI source-gen, or via opts.AddDetector<T>())
  • It doesn't expose detector-internal knobs — Configure<PiiLeakageDetector> can't set IncludePhoneNumbers = false. That's a per-detector-config feature still on the backlog. The universal knobs (Enabled/Floor/Cap/Action, and the thresholds for semantic detectors) cover the 90% case for any detector.
  • It doesn't fabricate findings — Floor only clamps firing results. If a detector emits Clean, no Floor will turn it into a finding.

Silent no-op for unmatched types​

Configure<T> keys on T (the runtime type). If you call Configure<NeverRegistered> for a type that isn't registered as a detector, the call silently no-ops — no exception, no warning. This avoids breaking ordering coupling between AddDetector and Configure.

A startup warning for unmatched type configurations is on the backlog. Today, double-check your detector type names if a Configure<T> call seems to have no effect.

A common gotcha: configuring an abstract base class:

// ❌ Doesn't work — pipeline keys on detector.GetType(), which is the concrete subclass
opts.Configure<SemanticDetectorBase>(c => c.Enabled = false);

// ✓ Configure each concrete type
opts.Configure<JailbreakDetector>(c => c.Enabled = false);
opts.Configure<MyJailbreakDetector>(c => c.Enabled = false);

Detector-author perspective​

Detector authors don't need to do anything special to support Configure<T> — the framework applies clamps post-invocation. Your detector should emit an honest severity given what fired; the operator decides what to do about it via Configure.

If your detector wants to expose detector-specific knobs (timeouts, threshold overrides), today the pattern is to subclass and override the relevant property:

public sealed class StricterJailbreakDetector(SentinelOptions opts) : JailbreakDetector(opts)
{
protected override float HighThreshold => 0.85f; // tighter than default 0.90
protected override float LowThreshold => 0.65f; // looser low bucket
}

services.AddAISentinel(opts =>
{
opts.AddDetector<StricterJailbreakDetector>();
});

A first-class detector-specific config API (opts.Configure<PiiLeakageDetector>(d => d.IncludePhoneNumbers = false)) is on the backlog under "Scope B" of the fluent-detector-config feature.

Next: Embedding cache — speed up semantic detection​