Retesting a Content Filter After a Fine-Tune Migration
10 min read · updated August 11, 2026
You retrained the fine-tune on a new base model and the content filter that sat quietly in front of it now rejects things it used to pass — or stops seeing anything at all. Both are the same problem viewed from different ends.
The symptom, in both directions
The first direction is over-blocking. Requests that used to complete now come back with your layer’s rejection — something like FilterError: response flagged, category self-harm/instructions — on inputs a human would call innocuous. Support tickets arrive describing a feature that “refuses to answer normal questions”.
The second is stranger and more common than teams expect: your filter’s metrics fall to near zero and stay there. Nothing is blocked because nothing reaches it. The provider’s own filtering, or the model’s trained refusal behaviour, is now intercepting the cases yours was built to catch, and it does so before your code runs. That is not a good outcome even though the numbers look clean, because you have lost the ability to say what your own control is doing — and the provider’s policy is not yours and can change without notice.
Three filters, and which ones moved
There are three independent gates between a user request and a rendered answer, and it is worth being pedantic about them because the whole fix depends on telling them apart.
- The provider’s platform filter. Runs outside the model, applies the provider’s usage policy, and surfaces as a structured signal rather than as text. On Chat Completions it appears as
finish_reasonequal tocontent_filter; on the Responses API asincomplete_details.reasonequal tocontent_filter; some deployments reject the request outright with a 400 rather than returning a response at all. A provider change moves this gate entirely. - The model’s own refusal behaviour. Learned during post-training, expressed either as prose or as a structured terminal reason — the Messages API enumerates
refusalas astop_reasonvalue. A fine-tune moves this gate, because your training data is now applied to a different base model whose refusal tendencies differ from the one you tuned against. - Your filter. Whatever you run on the output: a classification call, a moderation endpoint, a rule set. OpenAI’s moderation guide documents the shape most such layers are built on — a
flaggedboolean, acategoriesmap, acategory_scoresmap, andcategory_applied_input_types— across categories includingharassment,hate,illicit,self-harm,sexualandviolencewith their sub-categories. This gate is the only one you control, and it did not move — which is exactly why it is now mis-calibrated.
A migration moves gates one and two while leaving gate three fixed. Since gate three was tuned to compensate for the specific behaviour of the old gates one and two, it is now compensating for something that no longer exists.
Instrument attribution first
Do not change a threshold before you can answer “which layer blocked this?” for every blocked request. Most teams cannot, because the rejection is logged at the point where the user-facing error is produced, by which time the layer information has been thrown away.
type Block = {
layer: "provider" | "model" | "own";
evidence: string; // finish_reason / stop_reason / your category name
score?: number; // only meaningful for "own"
correlationId: string;
modelId: string; // the exact fine-tune id, so a retrain is distinguishable
};Two fields carry the weight. evidence should hold the raw signal verbatim — the terminal-reason value, or your own category name — so that a later change in the provider’s vocabulary is visible as a new string rather than as a shift in an already-normalised bucket. And modelId must be the exact fine-tuned model identifier including its job suffix, not a friendly name, so that two retrains of “the support model” are distinguishable in the data.
With that in place the two symptoms separate immediately. Over-blocking with a rising count of own blocks is a threshold problem in your layer. Falling own counts with rising provider or model counts is pre-emption, and the response is different: your layer is not broken, it is redundant for those categories, and the decision is whether to keep it as defence in depth or narrow it to the categories the upstream gates do not cover.
Re-running the corpus
The corpus is a labelled set of inputs with an expected disposition — allow or block, and for blocks, why. If you do not have one, this is the moment to build it, and it should come from real traffic rather than from imagination: the blocked requests you reviewed manually over the last quarter are the seed, and the false positives users complained about are the most valuable rows in it.
- Replay the corpus against the new fine-tune with your own filter disabled, recording only the terminal reason and the response. This measures gates one and two in isolation, which is the number you have never had.
- Replay again with your filter enabled, recording the full attribution object. The difference between the two runs is exactly what your layer contributes.
- Cross-tabulate: for each labelled category, how many were caught upstream, how many by you, how many by both, how many by neither. The “neither” cell is your real risk and the “both” cell is your real waste.
- Re-derive your thresholds from the “neither” and false-positive rows only. Tuning against the whole corpus optimises for cases that are already handled upstream.
- Record the model id, the corpus version and the date next to the new thresholds, so the next retrain starts from a documented position rather than from archaeology.
Keep the corpus and its labels in version control alongside the training data. It is the same asset class as the fine-tuning dataset discussed in what a migration does to fine-tuning artifacts, and it survives provider changes for the same reason: it is yours.
The compensation trap
The deeper lesson is about a class of layer rather than about filters specifically. A filter tuned to compensate for a particular model’s particular failure mode is a hidden dependency on that model. It works, it looks like a general safety control, and it is actually a patch pinned to a checkpoint. The tell is a threshold or a rule that nobody can justify from first principles — a category weighted unusually high, a phrase on a block list, a score cut-off at an odd value — because those are always the residue of a specific incident with a specific model.
Two habits keep it manageable. Write the reason next to the rule, in the rule, naming the model it was added for. And at every migration, remove the model-specific rules first and re-measure before adding anything back: usually a subset of them are no longer earning their false positives. What remains after that exercise is the part of your filter that reflects your own policy rather than a vendor’s, which is the part worth carrying forward — the distinction drawn on content policy differences.