Skip to main content

Response Rewriter

Use response-rewriter to apply deterministic text transformations to supported, non-streaming OpenAI-compatible JSON responses before the gateway returns them to the caller. It can replace, prepend, or append text while retaining the surrounding JSON envelope.

Outcome

ResultValue
Phaseoutput
Verdictalways allow
At least one rule changed textrewriter.applied
No supported text changedrewriter.no_match

Policy-result details contain:

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

structure_preserved is added when the gateway successfully rewrites the JSON body. rules_applied counts rule applications across response segments; one rule applied to two choices contributes 2.

The policy never blocks. A changed response can retain an overall final verdict of allow while the nested policy reason is rewriter.applied.

Prerequisites

  • The upstream must return a non-streaming JSON body in a supported Chat Completions or Responses shape.
  • Add response-rewriter to the effective policy chain and configure policy.response-rewriter.
  • Use blocking or redaction policies separately when unsafe content must not be delivered. Rewriting itself is allow-only.

Configuration

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

policies:
chain:
- response-rewriter

policy:
response-rewriter:
preserve_structure: true
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

FieldRequiredDefaultCurrent behavior
preserve_structurenotrueAccepted and reported. The current JSON handler preserves supported structures regardless of this value.
rulesno[]Ordered list of rewrite rules.
rules[].nameyesnoneRequired by schema. Current 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 guard on the current text segment.

The runtime has defensive defaults for missing rule fields, but schema-valid configs must include name and replacement.

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 rewrite top-level output strings, tool-call arguments, audio/image parts, arbitrary JSON fields, plain text bodies, or invalid JSON. An unsupported body is returned unchanged. A match-based replacement normally produces rewriter.no_match; an unconditional prepend/append can make the standalone extracted-text evaluation report rewriter.applied even when no supported body field was mutated. Verify the caller-visible body and structure_preserved, not the reason code alone.

Rule semantics

Rules run in array order for every supported text segment.

PositionMatching and mutation
replaceCompiles pattern with the runtime regex engine and replaces every match. Capture references such as $1 are supported.
prependIgnores pattern and adds replacement unless the segment already starts with that exact string.
appendIgnores pattern and adds replacement unless the segment already ends with that exact string.

condition is a case-insensitive substring test against the text as modified by earlier rules. An invalid replace regex is silently skipped and contributes 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.

preserve_structure boundary

For supported JSON responses, the current runtime always:

  1. parses the outer body;
  2. changes only supported string fields; and
  3. serializes the same surrounding JSON structure.

Setting preserve_structure: false does not switch to whole-body serialized text rewriting today. Keep it true to communicate the behavior you can actually rely on.

Streaming boundary

Do not rely on response-rewriter for stream: true SSE traffic. The current streaming path does not run this rewrite evaluator over accumulated output. Use stream: false when this transformation is required, and test streaming and non-streaming paths separately before rollout.

Validate and verify

kt policy test does not execute the gateway's response-rewrite handler. Use a running gateway and a controlled upstream response:

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

Send a non-streaming request:

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

curl -fsS http://127.0.0.1:41002/v1/chat/completions \
-H "Authorization: Bearer ${KEEPTRUSTS_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. The caller receives the expected rewritten field and an otherwise valid provider response envelope.
  2. kt events tail --since 10m --json shows nested reason rewriter.applied and the expected rules_applied.
  3. A negative request produces rewriter.no_match when no unconditional prepend/append rule is configured.

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

Rollout guidance

  • Start with one narrow replacement and representative positive/negative samples.
  • Check every response family used by the application; support for one JSON path does not imply support for another.
  • Measure added response latency because output inspection requires the body before mutation.
  • Keep a regression case for structure, not only the rewritten string.
  • Place blocking policies before cosmetic rewrites when chain order could make a later block unnecessary or confusing.

Troubleshooting

SymptomLikely causeResolution
rewriter.no_matchPattern or condition missed, regex was invalid, or the response path is unsupported.Inspect the actual upstream JSON and test the exact text segment.
Lint rejects a rulename or replacement is missing, or an enum/type is invalid.Add schema-required fields and use replace, prepend, or append.
Event says allow but content changedRewriting is allow-only.Inspect the nested response-rewriter result rather than expecting a block verdict.
preserve_structure: false still preserves JSONThe field is accepted/reported but does not alter the current body handler.Keep it true and do not depend on whole-body rewriting.
Streamed output is unchangedSSE output does not run this evaluator today.Require stream: false for this control.
One choice changed more than expectedRules run independently and in order on every supported segment.Narrow the pattern or add a condition; check rules_applied.

Next steps