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
| Result | Value |
|---|---|
| Phase | output |
| Verdict | always allow |
| At least one rule changed text | rewriter.applied |
| No supported text changed | rewriter.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-rewriterto the effective policy chain and configurepolicy.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
| Field | Required | Default | Current behavior |
|---|---|---|---|
preserve_structure | no | true | Accepted and reported. The current JSON handler preserves supported structures regardless of this value. |
rules | no | [] | Ordered list of rewrite rules. |
rules[].name | yes | none | Required by schema. Current result details report counts, not individual names. |
rules[].replacement | yes | none | Text used for replacement, prepend, or append. |
rules[].pattern | no | "" | Regex used only for position: replace. |
rules[].position | no | replace | replace, prepend, or append. |
rules[].condition | no | none | Non-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 family | Rewritten path |
|---|---|
| Chat Completions | choices[*].message.content |
| Responses | output[*].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.
| Position | Matching and mutation |
|---|---|
replace | Compiles pattern with the runtime regex engine and replaces every match. Capture references such as $1 are supported. |
prepend | Ignores pattern and adds replacement unless the segment already starts with that exact string. |
append | Ignores 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:
- parses the outer body;
- changes only supported string fields; and
- 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:
- The caller receives the expected rewritten field and an otherwise valid provider response envelope.
kt events tail --since 10m --jsonshows nested reasonrewriter.appliedand the expectedrules_applied.- A negative request produces
rewriter.no_matchwhen 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
| Symptom | Likely cause | Resolution |
|---|---|---|
rewriter.no_match | Pattern 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 rule | name 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 changed | Rewriting is allow-only. | Inspect the nested response-rewriter result rather than expecting a block verdict. |
preserve_structure: false still preserves JSON | The 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 unchanged | SSE output does not run this evaluator today. | Require stream: false for this control. |
| One choice changed more than expected | Rules run independently and in order on every supported segment. | Narrow the pattern or add a condition; check rules_applied. |
Next steps
- Request Rewriter — transform supported request fields before forwarding
- UPL Filter — add legal-advice-specific output controls
- Safety Filter — use a blocking output control where delivery must stop
- kt gateway run — perform transport-level verification
- Policy Controls Catalog — compare output controls