Skip to content

14 min read

Capturing the filter verdict

On this page

A content filter does not return a generic failure. It names the policy dimension that fired and, on most providers, the severity band it landed in, and it does so in a structured body next to a correlation id. That verdict is the only account anyone will ever give you of why a request failed, because the score behind it is not exposed and the provider will not decompose it on request.

The reason such a body exists is not that the provider is being generous with diagnostics. It falls out of how the classifier works. A content filter does not answer one question, it answers several at once — one score per policy category — and a block happens when at least one of those scores clears its threshold. So at the moment the request is refused, the provider is holding a small table of per-category results, and the error body is that table written down. This is why the field the response gives you is a category rather than a reason, and why it arrives whether or not anybody has ever asked for it. Reading the body is reading the classifier's own output, and it is the closest to the mechanism you will ever get from outside.

Most services throw it away. The usual shape is an HTTP client that catches the exception, stringifies it into a log message, and discards the response body, the headers and the correlation id — leaving a log line the service made up in place of the one the provider sent. This chapter is about capturing that signal instead: where to do it, what fields are load-bearing, how the same verdict is expressed differently on four platforms, and how to reproduce a block afterwards without handling the content that caused it.

The signal the provider already sent you

Here is a complete input-stage verdict from one platform, of the kind a clinical triage assistant receives when a structured risk assessment trips a harm category:

json
{
  "error": {
    "code": "content_filter",
    "message": "The response was filtered due to the prompt triggering the content management policy.",
    "param": "prompt",
    "innererror": {
      "code": "ResponsibleAIPolicyViolation",
      "content_filter_result": {
        "hate":      { "filtered": false, "severity": "safe" },
        "self_harm": { "filtered": true,  "severity": "medium" },
        "sexual":    { "filtered": false, "severity": "safe" },
        "violence":  { "filtered": false, "severity": "low" }
      }
    }
  }
}

Alongside it, headers carry a request id — apim-request-id: 4f0a2c3e-8b17-4d2a-9e55-1c8f0b6d7a91 — and a model deployment name. Read together, those eliminate most hypotheses in about ninety seconds. The self_harm category fired at medium severity on the prompt, not the completion, which the param field states outright. Nothing else fired. It is not a jailbreak detector, not a violence classifier reacting to clinical language about harm to others, not an output-side block on something the model wrote, and not a malformed request.

Each of those four is ruled out by a different piece of the body, and it is worth seeing which, because the same reading works on verdicts that look nothing like this one. param says prompt, which places the decision before the model and rules out anything the model wrote. The absence of a jailbreak entry rules out the prompt-injection detector. violence is reported and did not fire, which rules out the theory that clinical language about harm is being read as violent. And the presence of a content_filter_result object at all rules out a malformed request, because a request that failed validation never reached a classifier and comes back with a different error code entirely.

Two of those four eliminations rest on something the body does that is easy to walk past: it reports every category, including the ones that did not fire. Negative evidence is only available when absence is stated, and a body listing only the category that blocked would have left the violence hypothesis alive. The distinction between safe and low in that listing carries the same kind of information. violence came back low rather than safe, which says the classifier did find something in a clinical note about ending one's life that scores mildly on violence, and that it was nowhere near the configured threshold. That is a fact about how close this workload sits to a second boundary, and it costs nothing to have noticed it now rather than on the day the threshold moves.

It also reframes what kind of problem this is. Where the category that fired is the category the product is about — a clinical triage tool exists to process self-harm risk assessments — the block is not a prompt bug to be worked around by rephrasing. It is a mismatch between the deployment's policy configuration and the workload, and the fix, whatever it turns out to be, involves the provider.

Capture at the boundary, once, in a shape you can query

The instinct after a failure like that is to turn up logging everywhere. That is the wrong shape of fix, partly because volume is not the problem and partly because the content you most want is content you are least allowed to store.

Volume is not the problem because the loss is not gradual. A provider's error arrives as an exception object holding a response, and that response holds the body, the headers and the status. The moment any code calls str() on the exception and keeps only the result, everything that was not in the exception's own message is gone — not hard to find, gone, because the object it hung off has been dropped and the request will not be sent again. Every log statement after that point is downstream of the destruction, so turning ten of them into a hundred adds a hundred copies of a string that never contained the category. This is the property that decides where the capture belongs: it has to happen above the first place the object is flattened, and there is exactly one such place worth choosing.

Do the capture in one place: the adapter that wraps the provider call, meaning the single module that builds provider-shaped requests and turns provider-shaped responses back into your own types. Every provider call passes through it, it is the only code that understands provider-specific error bodies, and it is the natural place to translate them into the taxonomy from the previous chapter. Everything downstream then sees a PolicyBlock carrying fields it can query rather than a string it has to parse.

python
FILTER_FIELDS = ("hate", "self_harm", "sexual", "violence",
                 "jailbreak", "protected_material_text", "protected_material_code")

def extract_filter_signal(exc) -> dict:
    body    = exc.response.json().get("error", {})
    inner   = body.get("innererror", {})
    results = inner.get("content_filter_result", {})
    fired   = {k: v.get("severity", "detected")
               for k, v in results.items()
               if isinstance(v, dict) and (v.get("filtered") or v.get("detected"))}
    return {
        "provider":      "azure-openai",
        "deployment":    exc.request.url.path.split("/")[-3],
        "policy_code":   inner.get("code") or body.get("code"),
        "stage":         "input" if body.get("param") == "prompt" else "output",
        "categories":    sorted(fired),                       # ["self_harm"]
        "severities":    fired,                               # {"self_harm": "medium"}
        "request_id":    exc.response.headers.get("apim-request-id"),
        "http_status":   exc.response.status_code,
        "raw_error":     body.get("message"),
    }

Three of those fields are load-bearing in ways that are easy to miss. stage decides whether you were billed and whether the completion ever existed, which changes the cost of the failure by two orders of magnitude. deployment matters because policy configuration attaches to a deployment — one named, separately configured instance of a model that you create and call by name — rather than to an account: two deployments of the same model in the same subscription can enforce different thresholds, and "it works in staging" is very often "staging has a different filter configuration". request_id is the only token the provider's support engineers can look up on their side; without it, an appeal is you describing symptoms.

The one thing the record must not contain is the user's text. Store a content hash and a length instead, so repeated failures on the same document group together and you can prove that ten failures were one document retried ten times rather than ten distinct documents.

That grouping works because of one property of a hash and nothing more. A hash is a function from text of any length to a fixed-length string, and equal inputs always produce equal outputs. So two records carrying the same hash were produced by the same text, and counting distinct hashes counts distinct documents — which is the only question the incident actually needed answered, and it is answerable without holding a single character of anybody's note.

The limits are worth stating in the same breath, because a hash is routinely asked to do two jobs it cannot do. It does not explain anything: it groups failures and tells you nothing about why they failed, which is why the category fields sit beside it rather than being replaced by it. And it is not anonymisation for content somebody can guess. Hashing is one-directional in the sense that you cannot read the input back out of the digest, but anyone holding a candidate text can hash it and compare, so a hash of a short, formulaic string drawn from a small set of possibilities confirms which one it was. For a free-text clinical note the grouping is safe and useful. For a field with a handful of possible values it is a lookup table with extra steps.

json
{
  "event": "policy_block",
  "ts": "2026-08-04T09:41:07.412Z",
  "request_id": "4f0a2c3e-8b17-4d2a-9e55-1c8f0b6d7a91",
  "trace_id": "0af7651916cd43dd8448eb211c80319c",
  "reference": "RB-7QK4-22",
  "provider": "azure-openai", "deployment": "gpt-4o-clinical-eu",
  "endpoint": "/triage/assess", "cohort": "clinic-enterprise",
  "stage": "input", "policy_code": "ResponsibleAIPolicyViolation",
  "categories": ["self_harm"], "severities": {"self_harm": "medium"},
  "prompt_sha256": "9c1e…a7", "prompt_chars": 1417, "attachments": 0
}

reference is a short, human-typeable code shown to the user in the error card. It is the field that collapses a four-hour ticket triage into one lookup: a user reads eight characters over the phone, support pastes them into a search box, and the record above appears — with the provider's request id already attached, without anyone handling the original content.

The same signal wears different clothes on every platform

The extractor above is provider-specific by necessity, because no two platforms express the verdict the same way. A service calling more than one provider needs one extractor per provider and a single internal shape they all produce, and it is worth writing that mapping down before you need it at 09:41.

ProviderWhere the verdict livesCategory vocabularySeverity informationCorrelation id
Azure OpenAI400 body innererror.content_filter_result, or finish_reason: content_filter with content_filter_results per choicehate, self_harm, sexual, violence, plus jailbreak and protected-material detectors, the latter flagging text or code reproduced from copyrighted sourcesFour bands: safe, low, medium, highapim-request-id header
Anthropic API200 with stop_reason: "refusal" and a stop_details objectOpen set, e.g. cyber, bio, reasoning_extractionNone exposedrequest-id header, id on the message
Google GeminipromptFeedback.blockReason on input; candidates[].finishReason on outputHARM_CATEGORY_* constantsProbability bands rather than severityResponse metadata
Amazon Bedrock GuardrailsstopReason: guardrail_intervened plus a trace with per-policy assessmentsYour own configured topics and filters, alongside managed onesPer-policy confidenceInvocation id

Three structural differences fall out of that table and they change what your record can hold. Some platforms return a severity band and some return nothing but a category, so a normalised schema needs severity to be nullable rather than defaulted — a missing severity written as low will silently corrupt every comparison you make later. Some deliver the verdict as an error and some as a successful response, which means the extraction has to run on both the exception path and the success path. And on a guardrails-style platform the categories are partly yours, configured by whoever set the guardrail up, which makes the same field simultaneously a provider signal and a piece of your own configuration.

Severity bands deserve one caution. A band is a bucket over a continuous score, and the block decision is the band compared against a configured threshold. Two implications follow: a document at the top of low and one at the bottom of medium are nearly identical inputs with opposite outcomes, and a provider recalibrating where the band boundaries sit changes your block rate without changing any policy that was ever announced. Record the band you were given and resist reading it as a measure of how bad the content was.

Reproducing the block without real user content

Confirming the mechanism means sending something. The reproduction question is not "can I make the assistant fail", it is "can I make the same category fire at the same stage on wording that matches what users actually type".

Start from the structure of the failing workload rather than from imagination. A clinician-facing form has fixed field labels — presenting concern, risk indicators, protective factors, plan — and those labels are yours, not patient data. Compose a probe — a request sent purely to observe the verdict, whose answer nobody intends to use — out of the real scaffolding and synthetic content in the same register, and send it through the same adapter so the same code path produces the same signal:

python
PROBE = """Structured risk assessment. Complete the clinical summary.

Presenting concern: patient reports persistent thoughts of ending their life,
  present most days for three weeks.
Risk indicators: expressed intent, has considered method, recent bereavement.
Protective factors: lives with partner, engaged with services, no prior attempt.
Plan: assess need for same-day crisis referral."""

for label, text in [("full", PROBE), ("no_indicators", strip_section(PROBE, "Risk indicators"))]:
    r = adapter.call(deployment="gpt-4o-clinical-eu", prompt=text)
    print(label, r.blocked, r.categories, r.severities, r.request_id)

# full           True  ['self_harm'] {'self_harm': 'medium'} 6b21…
# no_indicators  True  ['self_harm'] {'self_harm': 'medium'} 8c04…

Two probes, both blocked, both on self_harm at medium, both on input. The mechanism is confirmed, the reproduction contains no patient data, and it took under a minute. Run the same probe against a second deployment before concluding anything about policy: if it passes on a deployment configured with a higher self-harm threshold, the input is at the boundary rather than deep inside the blocked region, and the configuration rather than the wording decides the outcome. If it fails everywhere, no realistic threshold saves you and the conversation moves from configuration to exemption.

What that second deployment buys is a measurement of something the provider never shows you, and the inference is worth spelling out because it is reusable. The classifier produces a score you cannot read; the verdict is that score compared against a threshold you can partly choose. Send identical bytes to two deployments that differ only in threshold and the score is the same in both, since it is a function of the input. So a block on the stricter deployment and a pass on the looser one places the score between the two thresholds. A block on both places it at or above the higher one. You have not seen the number, and you have bracketed it, which is enough to decide whether configuration or content is the thing standing in the way.

The inference holds only while the two deployments differ in configuration and in nothing else. Different regions, different model versions and different classifier versions each break the assumption that the score is the same on both sides, and when they do, a pass on the second deployment is evidence about the second deployment rather than about your input. Note down which of the two you have before you carry the result into a support ticket, because a provider engineer will ask, and "we tried another deployment and it worked" without that detail is not a finding they can use.

A category is not an explanation

Capturing the category tells you which classifier fired. It does not tell you why that classifier scored the text the way it did, because the score is not explainable in the response and the provider will not decompose it for you. You get a category and a band, not a highlighted span.

It also does not tell you whether anything changed. A self_harm: medium verdict today is consistent with a policy in place since the deployment was created and with a threshold that moved last night; a single observation cannot distinguish them. That takes a continuous measurement, which a later chapter builds. What the capture provides is the label that measurement needs — you cannot segment by policy category if you never recorded one.

Category names are also provider-local: self_harm on one platform, HARM_CATEGORY_DANGEROUS_CONTENT on another, a stop_details.category of bio or cyber on a third, a named guardrail policy on a fourth. Store the raw provider string rather than mapping it into a normalised enum on the way in, because the mapping is lossy and because the string is what you will quote in an appeal. Normalise later, in the query layer, where you can change your mind.

What the capture buys, then, is narrow and decisive: it converts a failure nobody can characterise into a failure with a category, a stage, a severity, a deployment and an identifier the provider recognises. That is enough to route the problem to the right team, to rule out four hypotheses in a minute, and to start a conversation with the vendor. It is not enough to hold a conversation with them, because a probe is not evidence — it shows that text in this shape trips the category, it does not isolate which part of the input is doing the work, it is longer than it needs to be, and it is not in a form you can attach to a support ticket or re-run as a check the next time policy moves. Getting from a probe to that artefact means cutting it down, carefully, because the real failing input is a patient's note and the cutting cannot be done on the original.

Provider content filters as an operational problem — read the current version at https://daviddominguez.dev/learn/en/provider-content-filters-as-an-operational-problem/capturing-the-filter-verdict

Back to top