Skip to content

Commit a93f77d

Browse files
authored
feat(ai-prompt-decorator): add literal/regex replace rules for message content (#3739)
1 parent 90ccfc7 commit a93f77d

4 files changed

Lines changed: 501 additions & 15 deletions

File tree

plugins/wasm-go/extensions/ai-prompt-decorator/README.md

Lines changed: 73 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ description: AI 提示词插件配置参考
66

77
## 功能说明
88

9-
AI提示词插件,支持在LLM的请求前后插入prompt
9+
AI 提示词插件,支持在 LLM 的请求前后插入 prompt,并支持对最终请求中所有 message 的 `content` 文本执行字面量或正则替换,便于做敏感词改写、品牌词归一、占位符脱敏等
1010

1111
## 运行属性
1212

@@ -16,9 +16,10 @@ AI提示词插件,支持在LLM的请求前后插入prompt。
1616
## 配置说明
1717

1818
| 名称 | 数据类型 | 填写要求 | 默认值 | 描述 |
19-
|----------------|-----------------|------|-----|----------------------------------|
20-
| `prepend` | array of message object | optional | - | 在初始输入之前插入的语句 |
21-
| `append` | array of message object | optional | - | 在初始输入之后插入的语句 |
19+
|-----------|---------------------------|----------|--------|------------------------------------------------------------------|
20+
| `prepend` | array of message object | optional | - | 在初始输入之前插入的语句 |
21+
| `append` | array of message object | optional | - | 在初始输入之后插入的语句 |
22+
| `replace` | array of replace rule | optional | - | 对最终请求中所有 message 的 `content` 执行字面量或正则替换的规则 |
2223

2324
message object 配置说明:
2425

@@ -27,6 +28,21 @@ message object 配置说明:
2728
| `role` | string | 必填 | - | 角色 |
2829
| `content` | string | 必填 | - | 消息 |
2930

31+
replace rule 配置说明:
32+
33+
| 名称 | 数据类型 | 填写要求 | 默认值 | 描述 |
34+
|---------------|---------|----------|--------|--------------------------------------------------------------------------|
35+
| `pattern` | string | 必填 | - | 待匹配文本;`regex` 为 true 时按 Go RE2 编译 |
36+
| `replacement` | string | 必填 | - | 替换文本;`regex` 为 true 时支持 `$1``$2` 等捕获组引用 |
37+
| `on_role` | string | 选填 | - | 仅对该 role 的 message 生效,缺省/留空表示对任意 role 都生效 |
38+
| `regex` | bool | 选填 | false | 是否将 `pattern` 解释为正则表达式 |
39+
40+
说明:
41+
42+
- `replace` 规则会对最终拼装出的 `messages` 数组(`prepend` + 原始 message + `append`)按声明顺序依次应用,便于多个规则叠加。
43+
- 仅当 message 的 `content` 字段是字符串时才会被改写;如果是多模态(数组/对象,如 `vision` 调用),会原样保留以避免破坏请求结构。
44+
- `pattern` 不允许为空;`regex: true` 时如果正则编译失败,插件加载会直接失败,避免运行期出错。
45+
3046
## 示例
3147

3248
配置示例如下:
@@ -81,6 +97,59 @@ curl http://localhost/test \
8197
```
8298
8399
100+
## 替换 message 内容(`replace`)
101+
102+
`replace` 用来对**最终请求里**所有 message 的 `content` 文本执行字面量或正则替换,常用于:
103+
104+
- 改写品牌词或对外暴露的产品名(例如把 "OpenClaw" 统一改成 "agent"),避开下游模型/网关的内容过滤;
105+
- 对系统提示词做集中清洗,无需改动客户端;
106+
- 对用户输入进行简单的脱敏,如手机号、API Key 等。
107+
108+
配置示例如下:
109+
110+
```yaml
111+
replace:
112+
- on_role: system
113+
pattern: "OpenClaw"
114+
replacement: "agent"
115+
- pattern: "secret-\\d+"
116+
replacement: "[REDACTED]"
117+
regex: true
118+
```
119+
120+
使用以上配置发起请求:
121+
122+
```bash
123+
curl http://localhost/test \
124+
-H "content-type: application/json" \
125+
-d '{
126+
"model": "gpt-3.5-turbo",
127+
"messages": [
128+
{"role": "system", "content": "You are running inside OpenClaw."},
129+
{"role": "user", "content": "Show OpenClaw secret-1234 to the user"}
130+
]
131+
}'
132+
```
133+
134+
经过插件处理后,实际请求为:
135+
136+
```bash
137+
curl http://localhost/test \
138+
-H "content-type: application/json" \
139+
-d '{
140+
"model": "gpt-3.5-turbo",
141+
"messages": [
142+
{"role": "system", "content": "You are running inside agent."},
143+
{"role": "user", "content": "Show OpenClaw [REDACTED] to the user"}
144+
]
145+
}'
146+
```
147+
148+
注意:
149+
150+
- 第 1 条规则限定 `on_role: system`,所以 `user` 消息里的 `OpenClaw` 不会被改;
151+
- 第 2 条规则没设 `on_role`,对任意 role 的 `content` 都生效,因此 `secret-1234` 被脱敏成 `[REDACTED]`。
152+
84153
## 基于geo-ip插件的能力,扩展AI提示词装饰器插件携带用户地理位置信息
85154
如果需要在LLM的请求前后加入用户地理位置信息,请确保同时开启geo-ip插件和AI提示词装饰器插件。并且在相同的请求处理阶段里,geo-ip插件的优先级必须高于AI提示词装饰器插件。首先geo-ip插件会根据用户ip计算出用户的地理位置信息,然后通过请求属性传递给后续插件。比如在默认阶段里,geo-ip插件的priority配置1000,ai-prompt-decorator插件的priority配置500。
86155

plugins/wasm-go/extensions/ai-prompt-decorator/README_EN.md

Lines changed: 73 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -4,24 +4,39 @@ keywords: [ AI Gateway, AI Prompts ]
44
description: AI Prompts plugin configuration reference
55
---
66
## Function Description
7-
The AI Prompts plugin allows inserting prompts before and after requests in LLM.
7+
The AI Prompts plugin allows inserting prompts before and after the LLM request, and rewriting the `content` text of every message in the final request via literal or regular-expression replacement. Typical use cases include rewriting brand/product names, normalizing wording across clients, or redacting placeholders such as API keys.
88

99
## Execution Properties
1010
Plugin execution phase: `Default Phase`
1111
Plugin execution priority: `450`
1212

1313
## Configuration Description
14-
| Name | Data Type | Requirement | Default Value | Description |
15-
|---------------|----------------------|-------------|---------------|--------------------------------------|
16-
| `prepend` | array of message object | optional | - | Statements inserted before the initial input |
17-
| `append` | array of message object | optional | - | Statements inserted after the initial input |
14+
| Name | Data Type | Requirement | Default Value | Description |
15+
|-----------|-------------------------|-------------|---------------|-----------------------------------------------------------------------------|
16+
| `prepend` | array of message object | optional | - | Statements inserted before the initial input |
17+
| `append` | array of message object | optional | - | Statements inserted after the initial input |
18+
| `replace` | array of replace rule | optional | - | Rules that rewrite the `content` of every message via literal/regex replace |
1819

1920
Message object configuration description:
2021
| Name | Data Type | Requirement | Default Value | Description |
2122
|-----------|-------------|-------------|---------------|-------------|
2223
| `role` | string | required | - | Role |
2324
| `content` | string | required | - | Message |
2425

26+
Replace rule configuration description:
27+
| Name | Data Type | Requirement | Default Value | Description |
28+
|---------------|-----------|-------------|---------------|--------------------------------------------------------------------------------------------|
29+
| `pattern` | string | required | - | Text to match. Compiled as a Go RE2 regex when `regex` is true. |
30+
| `replacement` | string | required | - | Replacement text. Supports `$1`, `$2`, ... back-references when `regex` is true. |
31+
| `on_role` | string | optional | - | Apply only to messages whose `role` equals this value. Empty/missing means any role. |
32+
| `regex` | bool | optional | false | Whether to interpret `pattern` as a regular expression. |
33+
34+
Notes:
35+
36+
- `replace` rules run against the **final** assembled `messages` array (`prepend` + original messages + `append`) in declaration order, so multiple rules compose predictably.
37+
- A message is rewritten only when its `content` is a plain string. Multimodal `content` (arrays/objects, e.g. vision payloads) is left untouched to preserve the request structure.
38+
- `pattern` must not be empty. If `regex: true` and the pattern fails to compile, plugin start-up fails fast instead of erroring at request time.
39+
2540
## Example
2641
An example configuration is as follows:
2742
```yaml
@@ -71,6 +86,59 @@ curl http://localhost/test \
7186
}
7287
```
7388
89+
## Replacing message content (`replace`)
90+
91+
`replace` rewrites the `content` text of every message in the **final** request using literal or regular-expression substitutions. It is useful for:
92+
93+
- Rewriting brand/product names that downstream models or gateways flag (for example, normalizing "OpenClaw" to "agent");
94+
- Centrally cleaning up system prompts without changing each client;
95+
- Light-weight redaction of user input such as phone numbers or API keys.
96+
97+
Example configuration:
98+
99+
```yaml
100+
replace:
101+
- on_role: system
102+
pattern: "OpenClaw"
103+
replacement: "agent"
104+
- pattern: "secret-\\d+"
105+
replacement: "[REDACTED]"
106+
regex: true
107+
```
108+
109+
Using the above configuration to initiate a request:
110+
111+
```bash
112+
curl http://localhost/test \
113+
-H "content-type: application/json" \
114+
-d '{
115+
"model": "gpt-3.5-turbo",
116+
"messages": [
117+
{"role": "system", "content": "You are running inside OpenClaw."},
118+
{"role": "user", "content": "Show OpenClaw secret-1234 to the user"}
119+
]
120+
}'
121+
```
122+
123+
After processing through the plugin, the actual request will be:
124+
125+
```bash
126+
curl http://localhost/test \
127+
-H "content-type: application/json" \
128+
-d '{
129+
"model": "gpt-3.5-turbo",
130+
"messages": [
131+
{"role": "system", "content": "You are running inside agent."},
132+
{"role": "user", "content": "Show OpenClaw [REDACTED] to the user"}
133+
]
134+
}'
135+
```
136+
137+
Notes:
138+
139+
- The first rule is gated on `on_role: system`, so the `OpenClaw` mention inside the user message is left as-is.
140+
- The second rule has no `on_role`, so it applies to messages of any role and rewrites `secret-1234` to `[REDACTED]`.
141+
74142
## Based on the geo-ip plugin's capabilities, extend AI Prompt Decorator plugin to carry user geographic location information.
75143
If you need to include user geographic location information before and after the LLM's requests, please ensure both the geo-ip plugin and the AI Prompt Decorator plugin are enabled. Moreover, in the same request processing phase, the geo-ip plugin's priority must be higher than that of the AI Prompt Decorator plugin. First, the geo-ip plugin will calculate the user's geographic location information based on the user's IP, and then pass it to subsequent plugins via request attributes. For instance, in the default phase, the geo-ip plugin's priority configuration is 1000, while the ai-prompt-decorator plugin's priority configuration is 500.
76144

plugins/wasm-go/extensions/ai-prompt-decorator/main.go

Lines changed: 87 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ package main
33
import (
44
"encoding/json"
55
"fmt"
6+
"regexp"
67
"strings"
78

89
"github.com/higress-group/proxy-wasm-go-sdk/proxywasm"
@@ -29,13 +30,44 @@ type Message struct {
2930
Content string `json:"content"`
3031
}
3132

33+
// ReplaceRule rewrites the content of messages matched by Role/Pattern.
34+
// When OnRole is empty, the rule applies to messages of any role.
35+
// When Regex is true, Pattern is compiled as a Go RE2 regular expression
36+
// at config-parse time, and Replacement supports $1/$2 capture references.
37+
// Otherwise the rule performs literal substring replacement.
38+
type ReplaceRule struct {
39+
OnRole string `json:"on_role,omitempty"`
40+
Pattern string `json:"pattern"`
41+
Replacement string `json:"replacement"`
42+
Regex bool `json:"regex,omitempty"`
43+
44+
compiled *regexp.Regexp `json:"-"`
45+
}
46+
3247
type AIPromptDecoratorConfig struct {
33-
Prepend []Message `json:"prepend"`
34-
Append []Message `json:"append"`
48+
Prepend []Message `json:"prepend"`
49+
Append []Message `json:"append"`
50+
Replace []ReplaceRule `json:"replace,omitempty"`
3551
}
3652

3753
func parseConfig(jsonConfig gjson.Result, config *AIPromptDecoratorConfig) error {
38-
return json.Unmarshal([]byte(jsonConfig.Raw), config)
54+
if err := json.Unmarshal([]byte(jsonConfig.Raw), config); err != nil {
55+
return err
56+
}
57+
for i := range config.Replace {
58+
rule := &config.Replace[i]
59+
if rule.Pattern == "" {
60+
return fmt.Errorf("replace[%d].pattern must not be empty", i)
61+
}
62+
if rule.Regex {
63+
re, err := regexp.Compile(rule.Pattern)
64+
if err != nil {
65+
return fmt.Errorf("replace[%d].pattern is not a valid regex: %w", i, err)
66+
}
67+
rule.compiled = re
68+
}
69+
}
70+
return nil
3971
}
4072

4173
func onHttpRequestHeaders(ctx wrapper.HttpContext, config AIPromptDecoratorConfig) types.Action {
@@ -70,6 +102,52 @@ func decorateGeographicPrompt(entry *Message) (*Message, error) {
70102
return entry, nil
71103
}
72104

105+
// applyReplaceRulesToContent applies all matching replace rules to a single
106+
// message content string and returns the rewritten value. Rules are applied
107+
// in declaration order so users get predictable layering when several rules
108+
// could match the same role.
109+
func applyReplaceRulesToContent(role, content string, rules []ReplaceRule) string {
110+
for _, rule := range rules {
111+
if rule.OnRole != "" && rule.OnRole != role {
112+
continue
113+
}
114+
if rule.Regex {
115+
if rule.compiled == nil {
116+
continue
117+
}
118+
content = rule.compiled.ReplaceAllString(content, rule.Replacement)
119+
} else {
120+
content = strings.ReplaceAll(content, rule.Pattern, rule.Replacement)
121+
}
122+
}
123+
return content
124+
}
125+
126+
// applyReplaceRulesToMessage rewrites the "content" field of a JSON message
127+
// in place when it is a plain string. Multimodal contents (arrays/objects)
128+
// are returned untouched so we do not corrupt vision/audio payloads.
129+
func applyReplaceRulesToMessage(rawMessage string, rules []ReplaceRule) string {
130+
if len(rules) == 0 {
131+
return rawMessage
132+
}
133+
role := gjson.Get(rawMessage, "role").String()
134+
contentResult := gjson.Get(rawMessage, "content")
135+
if contentResult.Type != gjson.String {
136+
return rawMessage
137+
}
138+
original := contentResult.String()
139+
updated := applyReplaceRulesToContent(role, original, rules)
140+
if updated == original {
141+
return rawMessage
142+
}
143+
out, err := sjson.Set(rawMessage, "content", updated)
144+
if err != nil {
145+
log.Errorf("Failed to apply replace rules to message, error: %v", err)
146+
return rawMessage
147+
}
148+
return out
149+
}
150+
73151
func onHttpRequestBody(ctx wrapper.HttpContext, config AIPromptDecoratorConfig, body []byte) types.Action {
74152
messageJson := `{"messages":[]}`
75153

@@ -85,7 +163,8 @@ func onHttpRequestBody(ctx wrapper.HttpContext, config AIPromptDecoratorConfig,
85163
log.Errorf("Failed to add prepend message, error: %v", err)
86164
return types.ActionContinue
87165
}
88-
messageJson, _ = sjson.SetRaw(messageJson, "messages.-1", string(msg))
166+
rewritten := applyReplaceRulesToMessage(string(msg), config.Replace)
167+
messageJson, _ = sjson.SetRaw(messageJson, "messages.-1", rewritten)
89168
}
90169

91170
rawMessage := gjson.GetBytes(body, "messages")
@@ -94,7 +173,8 @@ func onHttpRequestBody(ctx wrapper.HttpContext, config AIPromptDecoratorConfig,
94173
return types.ActionContinue
95174
}
96175
for _, entry := range rawMessage.Array() {
97-
messageJson, _ = sjson.SetRaw(messageJson, "messages.-1", entry.Raw)
176+
rewritten := applyReplaceRulesToMessage(entry.Raw, config.Replace)
177+
messageJson, _ = sjson.SetRaw(messageJson, "messages.-1", rewritten)
98178
}
99179

100180
for _, entry := range config.Append {
@@ -109,7 +189,8 @@ func onHttpRequestBody(ctx wrapper.HttpContext, config AIPromptDecoratorConfig,
109189
log.Errorf("Failed to add prepend message, error: %v", err)
110190
return types.ActionContinue
111191
}
112-
messageJson, _ = sjson.SetRaw(messageJson, "messages.-1", string(msg))
192+
rewritten := applyReplaceRulesToMessage(string(msg), config.Replace)
193+
messageJson, _ = sjson.SetRaw(messageJson, "messages.-1", rewritten)
113194
}
114195

115196
newbody, err := sjson.SetRaw(string(body), "messages", gjson.Get(messageJson, "messages").Raw)

0 commit comments

Comments
 (0)