Skip to content

Detection

Quayside’s detection model is thin-shim. The core ships a small adapter interface; the actual detection logic lives in adapters — some bundled, most provided by you or by external services your adapter calls.

Adapter Protocol

Every detector implements this Protocol:

class DetectorAdapter(Protocol):
name: str
async def inspect(
self, content: str, *, direction: Direction, context: RequestContext,
) -> Verdict: ...
def inspect_stream(
self, chunks: AsyncIterator[str], *, context: RequestContext,
) -> AsyncIterator[Verdict]: ...
  • name — what a policy refers to in stages[].detectors[] and detectors[]
  • inspect() — single-shot. Used for request-side full-prompt and response-side final-pass.
  • inspect_stream() — per-chunk. Inherit WindowedStreamingDetector to get a default that accumulates and re-inspects every N characters.

A Verdict carries (detector, effect, score?, reason?, matched?).

Effect lattice

Verdicts combine via most-restrictive-wins:

Allow < Flag < Modify < Approve < Block

combine([verdicts]) returns the max. An empty list is Allow.

EffectMeaning
AllowPass through
FlagPass through, recorded as a Flag step
Modify(v1: log only; substitution is later)
Approve(v1: not yet routed; HITL surface lands later)
BlockRefuse — 403 pre-flight, SSE event: error mid-stream

Three classes of detector

ClassLives whereExamplesRound-trip cost
In-processInline in the quayside serviceRegexDetector, NullDetector, custom LLMJudgeDetector<1 ms
Self-hosted serviceInside your VPCPresidioDetector against a Presidio Analyzer sidecar5–30 ms
Hosted SaaSVendor’s infrastructure(stub) LakeraDetector, AnthropicModerationDetector50–200 ms

The Protocol is the same. The trade-offs differ — latency, cost-per-call, data residency.

Bundled adapters

NullDetector

Always returns Allow. Useful in tests and as a baseline.

RegexDetector

In-process pattern matcher.

RegexDetector(
patterns=[
{"name": "ssn", "pattern": r"\d{3}-\d{2}-\d{4}", "score": 0.95},
{"name": "email", "pattern": r"[\w.+-]+@[\w.-]+", "score": 0.5,
"category": "EMAIL"},
],
flag_threshold=0.5,
block_threshold=0.85,
case_insensitive=True,
)
  • Finds all matching patterns; takes max(score)
  • Maps the max to Effect via the thresholds
  • reason lists matched pattern names; matched carries categories

PresidioDetector

HTTP call to a Microsoft Presidio Analyzer.

PresidioDetector(
endpoint="http://presidio.internal:5002",
entities=["EMAIL_ADDRESS", "US_SSN", "PHONE_NUMBER"],
language="en",
score_threshold=0.4, # forwarded to Presidio for server-side filtering
flag_threshold=0.5,
block_threshold=0.85,
)
  • POSTs to <endpoint>/analyze
  • Takes the max score across findings
  • matched carries the sorted list of entity types found

HTTPStatusError propagates so the proxy’s fail_mode / on_failure machinery does the translation — don’t translate failures inside the adapter.

How they fit together

In one policy you can mix all three:

stages:
- name: cheap-inline # in-process — fast
direction: both
detectors: [regex_pii]
timeout_ms: 100
- name: full-pii # self-hosted — slower, more accurate
direction: both
detectors: [presidio]
timeout_ms: 2000
- name: prompt-injection # hosted SaaS
direction: request
detectors: [lakera_guard]
timeout_ms: 500

The cascade runs them in order. A Block at any stage halts.

Writing your own

See Guides → Write a custom detector.