Skip to main content

Response Rewriter

Use response-rewriter to change text in supported OpenAI-compatible JSON responses. The responses must not use streaming. The policy can replace, prepend, or append text. It keeps the other JSON fields.

Outcome

ResultValue
Phaseoutput
Verdictalways allow
A minimum of one rule changed textrewriter.applied
No supported text changedrewriter.no_match

Policy-result details contain:

{
"rules_evaluated": 2,
"rules_applied": 2,
"structure_preserved": true
}

The gateway adds structure_preserved after it changes the JSON body. rules_applied counts applications across response segments. One rule on two segments contributes 2.

The policy does not block. A changed response keeps the last verdict allow. Its nested cause can be rewriter.applied.

Prerequisites

  • Make sure that the upstream returns a non-streaming JSON body in a supported response shape.
  • Add response-rewriter to the effective policy chain and configure policy.response-rewriter.
  • Use a different block or redaction policy for dangerous content. Rewriting always allows the response.

Configuration

pack:
name: response-rewriter-example-1
version: "1.0.0"
enabled: true

policies:
chain:
- response-rewriter

policy:
response-rewriter:
rules:
- name: append-disclaimer
replacement: "\n\nAI output — verify independently."
position: append
- name: redact-email
pattern: '[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}'
replacement: "[EMAIL]"
position: replace

Supported fields

FieldNecessaryDefaultActive behavior
rulesno[]List of rewrite rules in sequence.
rules[].nameyesnoneThe schema makes this field necessary. Active result details report counts, not individual names.
rules[].replacementyesnoneText used for replacement, prepend, or append.
rules[].patternno""Regex used only for position: replace.
rules[].positionnoreplacereplace, prepend, or append.
rules[].conditionnononeNon-empty, case-insensitive substring condition on the input text segment.

A correct config must include name and replacement for each rule.

Supported response fields

Only string values at these paths are changed:

Response familyRewritten path
Chat Completionschoices[*].message.content
Responsesoutput[*].content[*].text

The policy does not change top-level output strings or tool-call arguments. It also keeps audio, images, arbitrary JSON, plain text, and invalid JSON. The gateway returns an unsupported body without changes.

A match-based replacement usually causes rewriter.no_match. An unconditional prepend or append can report rewriter.applied without a supported body change. Verify the caller body and structure_preserved. Do not use only the cause code.

Rule semantics

Rules run in array sequence for each supported text segment.

PositionMatching and mutation
replaceCompiles pattern with the runtime regex engine. It replaces each match and supports capture references such as $1.
prependIgnores pattern and adds replacement unless the segment starts with that specified string.
appendIgnores pattern and adds replacement unless the segment ends with that specified string.

condition is a substring test without case sensitivity. It checks text after earlier rules. The runtime skips an invalid replace regex. It records zero applications.

Capture-group example

policy:
response-rewriter:
rules:
- name: normalize-ticket-reference
pattern: 'ticket ([0-9]+)'
replacement: 'case $1'

Review ticket 4821 becomes Review case 4821.

Structure preservation

For supported JSON responses, the runtime does these steps:

  1. Parse the outer body.
  2. Change only supported string fields.
  3. Serialize the same other JSON fields.

The policy does not give a whole-body text mode.

Streaming boundary

Do not use response-rewriter for stream: true SSE traffic. The streaming path does not run this evaluator. Use stream: false when this change is necessary. Test each transport path before rollout.

Validate and verify

verdictan policy test does not run the gateway response-rewrite handler. Use a running gateway and a controlled upstream response:

verdictan policy lint --file policy-config.yaml
verdictan gateway run \
--listen 127.0.0.1:41002 \
--agent response-rewrite-verification \
--policy-config policy-config.yaml

Send a non-streaming request:

export VERDICTAN_CLIENT_TOKEN="replace-with-separate-client-token"

curl -fsS http://127.0.0.1:41002/v1/chat/completions \
-H "Authorization: Bearer ${VERDICTAN_CLIENT_TOKEN}" \
-H "Content-Type: application/json" \
-d '{
"model": "replace-with-model-id",
"stream": false,
"messages": [
{
"role": "user",
"content": "Reply with one short sentence."
}
]
}'

Verify all three layers:

  1. Verify that the caller receives the specified changed field and a correct provider response envelope.
  2. Verify that verdictan events tail --since 10m --json shows rewriter.applied and the specified rules_applied.
  3. Send a negative request when no unconditional prepend or append rule is available.
  4. Verify that this request causes rewriter.no_match.

Use a deterministic test upstream for rollout tests. Model wording is not a stable test fixture for a regex contract.

Rollout guidance

  • Start with one narrow replacement. Test typical matching and nonmatching samples.
  • Examine each response family that the application uses. Support for one JSON path does not prove support for a different response family.
  • Measure added response latency. The policy must examine the body before it changes the response.
  • Keep a regression case for structure, not only the rewritten string.
  • Put block policies before cosmetic rewrites. Then, a block stops the rewrite.

Troubleshooting

SymptomLikely causeResolution
rewriter.no_matchPattern or condition missed, regex was invalid, or the response path is unsupported.Examine the actual upstream JSON and test the specified text segment.
Lint rejects a rulename or replacement is missing, or an enum/type is invalid.Add the necessary schema fields and use replace, prepend, or append.
Event says allow but content changedRewriting is allow-only.Examine the nested response-rewriter result. A block verdict does not occur.
Streamed output is unchangedSSE output does not run this evaluator today.Use stream: false for this control.
One segment changed more than specifiedRules run independently and in sequence on each supported segment.Narrow the pattern or add a condition. Examine rules_applied.

Next steps