Skip to content

Mapping Content Moderation and Safety Fields Between APIs

9 min read · updated August 11, 2026

A safety block is a single event: the provider decided not to give you what the model would otherwise have produced. Every major API reports it differently, and at least one of them reports it in a way that your existing code will read as success.

Four places a block can appear

Before comparing field names, it is worth separating the four structurally different ways a provider can tell you about a filter, because the code that has to handle them is different in each case:

  • A separate classification call. You send the text to a dedicated moderation endpoint and get back labels. Nothing about the generation call changes.
  • A field on an otherwise successful response. HTTP 200, a well-formed body, and a value in the finish-reason field saying the output was cut off or withheld.
  • An absent result. HTTP 200, a well-formed body, and the array where the answer normally sits is empty. The reason is in a sibling object.
  • An HTTP error. A 400 with a structured error body naming the policy that fired.

The second and third are the ones that hurt. A migration that hardens error handling and retries around exceptions will sail straight past both of them, and the symptom downstream is an empty string, a truncated answer, or an index error on an array you assumed had one element.

The OpenAI shape: a separate endpoint and a finish reason

OpenAI splits the job. Classification lives on its own endpoint, POST /v1/moderations, which takes input text and returns a results array whose entries carry a boolean flagged, a categories object of booleans, and a category_scores object of floats between 0 and 1. The category keys are slash-separated strings such as harassment, hate/threatening, self-harm/instructions and violence/graphic. Because it is a separate call it tells you nothing about what happened during generation, and because it is free of the generation call it is the one piece here that ports trivially: you can keep calling it after you have moved generation elsewhere.

Generation-time filtering surfaces on the completion itself. In Chat Completions the value content_filter appears in choices[0].finish_reason, alongside stop, length and tool_calls. Separately, a model that declines to answer on its own can populate choices[0].message.refusal with a string while content is null — that is a model-level refusal, not a filter, and the two mean different things. The library covers the full value list in the page on finish_reason values and the refusal path in moderation and model refusal.

Azure’s hosting of the same models adds a third layer that catches many migrations in the opposite direction. Azure returns prompt_filter_results for the input and content_filter_results per choice, each holding categories such as hate, self_harm, sexual and violence with a filtered boolean and a severity band rather than a score. When the input itself trips the filter, Azure returns HTTP 400 with an error code of content_filter. Code written against OpenAI directly has no branch for either.

The Anthropic shape: a stop reason

Anthropic publishes no separate moderation endpoint, so there is nothing to map the classification call onto — if you were using labels and scores to route or to log, that half of the system stays where it is or gets rebuilt. What Anthropic does report is a stop_reason on the message object, which is the same slot as OpenAI’s finish_reason under a different name and with a different value set. The documented values include end_turn, max_tokens, stop_sequence, tool_use, pause_turn and refusal.

Two of those pairs are the whole mapping problem in miniature. end_turn and stop mean the same thing. max_tokens and length mean the same thing. tool_use and tool_calls mean the same thing. But refusal is not content_filter: it marks the model declining, which on the OpenAI side is the refusal message field rather than a finish-reason value. Anything that maps by position — the fourth value here becomes the fourth value there — will get this wrong. Map by meaning, name by name, and leave a hole where there is no counterpart.

Stop-reason and finish-reason value sets grow. Both of these are the documented sets at the time of writing; treat an unrecognised value as a first-class case rather than as an assertion failure, because a new one will eventually arrive on a response your code has already parsed.

The Google shape: ratings, a block reason, and no candidate

Google’s Gemini API takes the third structural approach and it is the one most likely to crash existing code. Safety is expressed on the request as a safetySettings array of category-and-threshold pairs, using category constants of the form HARM_CATEGORY_HARASSMENT and thresholds such as BLOCK_ONLY_HIGH or BLOCK_MEDIUM_AND_ABOVE. On the response, each candidate carries safetyRatings with an ordinal probability band rather than a score, and candidates[].finishReason carries values including STOP, MAX_TOKENS, SAFETY and RECITATION.

The dangerous case is an input-side block. When the prompt itself is rejected there is no candidate at all: the candidates array is absent or empty, and the reason lives in promptFeedback.blockReason. Every migration that ports response.choices[0].message.content to response.candidates[0].content.parts[0].text as a mechanical rewrite has, at that moment, written a line that throws on a class of input it never threw on before. It will pass every test with benign fixtures.

Normalising to one field

The tractable shape is a small enum your application owns, populated by a per-provider adapter, with the raw payload kept alongside it for logging. Four states cover everything above, and they are genuinely distinct in what the caller should do next:

type Safety =
  | { kind: "ok" }
  | { kind: "input_blocked";  categories: string[]; raw: unknown }
  | { kind: "output_blocked"; categories: string[]; raw: unknown }
  | { kind: "model_refused";  message: string | null; raw: unknown };

// OpenAI Chat Completions
const c = res.choices[0];
if (c.finish_reason === "content_filter") return { kind: "output_blocked", ... };
if (c.message.refusal) return { kind: "model_refused", message: c.message.refusal, ... };

// Anthropic Messages
if (msg.stop_reason === "refusal") return { kind: "model_refused", message: null, ... };

// Gemini
if (!res.candidates?.length) return { kind: "input_blocked", ... };
if (res.candidates[0].finishReason === "SAFETY") return { kind: "output_blocked", ... };

The distinction between input_blocked and output_blocked is worth keeping even though two of the three providers above will only ever produce one of them, because it is the difference between “do not retry, the user’s text is the problem” and “a retry may well succeed”. Collapsing them costs you a retry policy.

What does not survive

Three things are genuinely lost and no adapter recovers them. First, scores do not compare. A float between 0 and 1 from a dedicated classifier and a four-level ordinal band from a generation endpoint are not measuring the same quantity on the same scale, and any threshold you tuned against one is meaningless against the other. If you routed on category_scores.violence > 0.4, that number does not carry; you re-derive the threshold from your own labelled examples on the new signal or you drop the routing rule.

Second, category taxonomies do not align. The slash-separated OpenAI categories, the Azure four, and the Gemini HARM_CATEGORY_ constants partition the space differently, and the differences are not a renaming. Mapping them into one vocabulary means deciding, in your own product’s terms, which distinctions you actually act on — usually far fewer than either list offers.

Third, configurability does not port. Where one provider lets you set per-category thresholds on the request, another applies a fixed policy you cannot address at all. A migration that moves from the configurable side to the fixed side is not losing a field; it is losing a control, and the compensating work is application-level — a pre-flight classification call, a stricter system prompt, or an output check you run yourself. Decide which before the cutover, because after it the only signal you have is a value in a finish-reason field.

When you are through this, the same discipline applies to every other response field you assumed the shape of; the audit of hardcoded provider assumptions is the systematic version.