From 6c3fd46c6fb29c1421cebdcbb0ed26d1e9dbc8e8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=BE=84=E6=BD=AD?= Date: Fri, 30 Jan 2026 17:56:31 +0800 Subject: [PATCH] feat(ai-proxy): add context cleanup command support (#3409) --- plugins/wasm-go/extensions/ai-proxy/README.md | 82 ++++++ .../wasm-go/extensions/ai-proxy/README_EN.md | 88 ++++++ .../extensions/ai-proxy/provider/provider.go | 23 ++ .../ai-proxy/provider/request_helper.go | 67 +++++ .../ai-proxy/provider/request_helper_test.go | 253 ++++++++++++++++++ 5 files changed, 513 insertions(+) create mode 100644 plugins/wasm-go/extensions/ai-proxy/provider/request_helper_test.go diff --git a/plugins/wasm-go/extensions/ai-proxy/README.md b/plugins/wasm-go/extensions/ai-proxy/README.md index 56877a21e..85ba9c377 100644 --- a/plugins/wasm-go/extensions/ai-proxy/README.md +++ b/plugins/wasm-go/extensions/ai-proxy/README.md @@ -57,6 +57,7 @@ description: AI 代理插件配置参考 | `reasoningContentMode` | string | 非必填 | - | 如何处理大模型服务返回的推理内容。目前支持以下取值:passthrough(正常输出推理内容)、ignore(不输出推理内容)、concat(将推理内容拼接在常规输出内容之前)。默认为 passthrough。仅支持通义千问服务。 | | `capabilities` | map of string | 非必填 | - | 部分 provider 的部分 ai 能力原生兼容 openai/v1 格式,不需要重写,可以直接转发,通过此配置项指定来开启转发, key 表示的是采用的厂商协议能力,values 表示的真实的厂商该能力的 api path, 厂商协议能力当前支持: openai/v1/chatcompletions, openai/v1/embeddings, openai/v1/imagegeneration, openai/v1/audiospeech, cohere/v1/rerank | | `subPath` | string | 非必填 | - | 如果配置了subPath,将会先移除请求path中该前缀,再进行后续处理 | +| `contextCleanupCommands` | array of string | 非必填 | - | 上下文清理命令列表。当请求的 messages 中存在完全匹配任意一个命令的 user 消息时,将该消息及之前所有非 system 消息清理掉,只保留 system 消息和该命令之后的消息。可用于主动清理对话上下文。 | `context`的配置字段说明如下: @@ -2389,11 +2390,92 @@ providers: } ``` +### 使用上下文清理命令 +配置上下文清理命令后,用户可以通过发送特定消息来主动清理对话历史,实现"重新开始对话"的效果。 +**配置信息** +```yaml +provider: + type: qwen + apiTokens: + - "YOUR_QWEN_API_TOKEN" + modelMapping: + "*": "qwen-turbo" + contextCleanupCommands: + - "清理上下文" + - "/clear" + - "重新开始" + - "新对话" +``` +**请求示例** +当用户发送包含清理命令的请求时: + +```json +{ + "model": "gpt-3", + "messages": [ + { + "role": "system", + "content": "你是一个助手" + }, + { + "role": "user", + "content": "你好" + }, + { + "role": "assistant", + "content": "你好!有什么可以帮助你的?" + }, + { + "role": "user", + "content": "今天天气怎么样" + }, + { + "role": "assistant", + "content": "抱歉,我无法获取实时天气信息。" + }, + { + "role": "user", + "content": "清理上下文" + }, + { + "role": "user", + "content": "现在开始新话题,介绍一下你自己" + } + ] +} +``` + +**实际发送给 AI 服务的请求** + +插件会自动清理"清理上下文"命令及之前的所有非 system 消息: + +```json +{ + "model": "qwen-turbo", + "messages": [ + { + "role": "system", + "content": "你是一个助手" + }, + { + "role": "user", + "content": "现在开始新话题,介绍一下你自己" + } + ] +} +``` + +**说明** + +- 清理命令必须完全匹配配置的字符串,部分匹配不会触发清理 +- 当存在多个清理命令时,只处理最后一个匹配的命令 +- 清理会保留所有 system 消息,删除命令及之前的 user、assistant、tool 消息 +- 清理命令之后的所有消息都会保留 ## 完整配置示例 diff --git a/plugins/wasm-go/extensions/ai-proxy/README_EN.md b/plugins/wasm-go/extensions/ai-proxy/README_EN.md index 3a736753a..3699e8855 100644 --- a/plugins/wasm-go/extensions/ai-proxy/README_EN.md +++ b/plugins/wasm-go/extensions/ai-proxy/README_EN.md @@ -52,6 +52,7 @@ Plugin execution priority: `100` | `context` | object | Optional | - | Configuration for AI conversation context information | | `customSettings` | array of customSetting | Optional | - | Specifies overrides or fills parameters for AI requests | | `subPath` | string | Optional | - | If subPath is configured, the prefix will be removed from the request path before further processing. | +| `contextCleanupCommands` | array of string | Optional | - | List of context cleanup commands. When a user message in the request exactly matches any of the configured commands, that message and all non-system messages before it will be removed, keeping only system messages and messages after the command. This enables users to actively clear conversation history. | **Details for the `context` configuration fields:** @@ -2147,6 +2148,93 @@ providers: } ``` +### Using Context Cleanup Commands + +After configuring context cleanup commands, users can actively clear conversation history by sending specific messages, achieving a "start over" effect. + +**Configuration** + +```yaml +provider: + type: qwen + apiTokens: + - "YOUR_QWEN_API_TOKEN" + modelMapping: + "*": "qwen-turbo" + contextCleanupCommands: + - "clear context" + - "/clear" + - "start over" + - "new conversation" +``` + +**Request Example** + +When a user sends a request containing a cleanup command: + +```json +{ + "model": "gpt-3", + "messages": [ + { + "role": "system", + "content": "You are an assistant" + }, + { + "role": "user", + "content": "Hello" + }, + { + "role": "assistant", + "content": "Hello! How can I help you?" + }, + { + "role": "user", + "content": "What's the weather like today" + }, + { + "role": "assistant", + "content": "Sorry, I cannot get real-time weather information." + }, + { + "role": "user", + "content": "clear context" + }, + { + "role": "user", + "content": "Let's start a new topic, introduce yourself" + } + ] +} +``` + +**Actual Request Sent to AI Service** + +The plugin automatically removes the cleanup command and all non-system messages before it: + +```json +{ + "model": "qwen-turbo", + "messages": [ + { + "role": "system", + "content": "You are an assistant" + }, + { + "role": "user", + "content": "Let's start a new topic, introduce yourself" + } + ] +} +``` + +**Notes** + +- The cleanup command must exactly match the configured string; partial matches will not trigger cleanup +- When multiple cleanup commands exist in messages, only the last matching command is processed +- Cleanup preserves all system messages and removes user, assistant, and tool messages before the command +- All messages after the cleanup command are preserved + ## Full Configuration Example ### Kubernetes Example diff --git a/plugins/wasm-go/extensions/ai-proxy/provider/provider.go b/plugins/wasm-go/extensions/ai-proxy/provider/provider.go index d0983402a..f7cebccf7 100644 --- a/plugins/wasm-go/extensions/ai-proxy/provider/provider.go +++ b/plugins/wasm-go/extensions/ai-proxy/provider/provider.go @@ -421,6 +421,9 @@ type ProviderConfig struct { // @Title zh-CN generic Provider 对应的Host // @Description zh-CN 仅适用于generic provider,用于覆盖请求转发的目标Host genericHost string `required:"false" yaml:"genericHost" json:"genericHost"` + // @Title zh-CN 上下文清理命令 + // @Description zh-CN 配置清理命令文本列表,当请求的 messages 中存在完全匹配任意一个命令的 user 消息时,将该消息及之前所有非 system 消息清理掉,实现主动清理上下文的效果 + contextCleanupCommands []string `required:"false" yaml:"contextCleanupCommands" json:"contextCleanupCommands"` // @Title zh-CN 首包超时 // @Description zh-CN 流式请求中收到上游服务第一个响应包的超时时间,单位为毫秒。默认值为 0,表示不开启首包超时 firstByteTimeout uint32 `required:"false" yaml:"firstByteTimeout" json:"firstByteTimeout"` @@ -461,6 +464,10 @@ func (c *ProviderConfig) GetVllmServerHost() string { return c.vllmServerHost } +func (c *ProviderConfig) GetContextCleanupCommands() []string { + return c.contextCleanupCommands +} + func (c *ProviderConfig) IsOpenAIProtocol() bool { return c.protocol == protocolOpenAI } @@ -639,6 +646,12 @@ func (c *ProviderConfig) FromJson(json gjson.Result) { c.vllmServerHost = json.Get("vllmServerHost").String() c.vllmCustomUrl = json.Get("vllmCustomUrl").String() c.doubaoDomain = json.Get("doubaoDomain").String() + c.contextCleanupCommands = make([]string, 0) + for _, cmd := range json.Get("contextCleanupCommands").Array() { + if cmd.String() != "" { + c.contextCleanupCommands = append(c.contextCleanupCommands, cmd.String()) + } + } } func (c *ProviderConfig) Validate() error { @@ -949,6 +962,16 @@ func (c *ProviderConfig) handleRequestBody( log.Debugf("[Auto Protocol] converted Claude request body to OpenAI format") } + // handle context cleanup command for chat completion requests + if apiName == ApiNameChatCompletion && len(c.contextCleanupCommands) > 0 { + body, err = cleanupContextMessages(body, c.contextCleanupCommands) + if err != nil { + log.Warnf("[contextCleanup] failed to cleanup context messages: %v", err) + // Continue processing even if cleanup fails + err = nil + } + } + // use openai protocol (either original openai or converted from claude) if handler, ok := provider.(TransformRequestBodyHandler); ok { body, err = handler.TransformRequestBody(ctx, apiName, body) diff --git a/plugins/wasm-go/extensions/ai-proxy/provider/request_helper.go b/plugins/wasm-go/extensions/ai-proxy/provider/request_helper.go index 665ba5fa8..55e496ab7 100644 --- a/plugins/wasm-go/extensions/ai-proxy/provider/request_helper.go +++ b/plugins/wasm-go/extensions/ai-proxy/provider/request_helper.go @@ -73,6 +73,73 @@ func insertContextMessage(request *chatCompletionRequest, content string) { } } +// cleanupContextMessages 根据配置的清理命令清理上下文消息 +// 查找最后一个完全匹配任意 cleanupCommands 的 user 消息,将该消息及之前所有非 system 消息清理掉,只保留 system 消息 +func cleanupContextMessages(body []byte, cleanupCommands []string) ([]byte, error) { + if len(cleanupCommands) == 0 { + return body, nil + } + + request := &chatCompletionRequest{} + if err := json.Unmarshal(body, request); err != nil { + return body, fmt.Errorf("unable to unmarshal request for context cleanup: %v", err) + } + + if len(request.Messages) == 0 { + return body, nil + } + + // 从后往前查找最后一个匹配任意清理命令的 user 消息 + cleanupIndex := -1 + for i := len(request.Messages) - 1; i >= 0; i-- { + msg := request.Messages[i] + if msg.Role == roleUser { + content := msg.StringContent() + for _, cmd := range cleanupCommands { + if content == cmd { + cleanupIndex = i + break + } + } + if cleanupIndex != -1 { + break + } + } + } + + // 没有找到匹配的清理命令 + if cleanupIndex == -1 { + return body, nil + } + + log.Debugf("[contextCleanup] found cleanup command at index %d, cleaning up messages", cleanupIndex) + + // 构建新的消息列表: + // 1. 保留 cleanupIndex 之前的 system 消息(只保留 system,其他都清理) + // 2. 删除 cleanupIndex 位置的清理命令消息 + // 3. 保留 cleanupIndex 之后的所有消息 + var newMessages []chatMessage + + // 处理 cleanupIndex 之前的消息,只保留 system + for i := 0; i < cleanupIndex; i++ { + msg := request.Messages[i] + if msg.Role == roleSystem { + newMessages = append(newMessages, msg) + } + } + + // 跳过 cleanupIndex 位置的消息(清理命令本身) + // 保留 cleanupIndex 之后的所有消息 + for i := cleanupIndex + 1; i < len(request.Messages); i++ { + newMessages = append(newMessages, request.Messages[i]) + } + + request.Messages = newMessages + log.Debugf("[contextCleanup] messages after cleanup: %d", len(newMessages)) + + return json.Marshal(request) +} + func ReplaceResponseBody(body []byte) error { log.Debugf("response body: %s", string(body)) err := proxywasm.ReplaceHttpResponseBody(body) diff --git a/plugins/wasm-go/extensions/ai-proxy/provider/request_helper_test.go b/plugins/wasm-go/extensions/ai-proxy/provider/request_helper_test.go new file mode 100644 index 000000000..ee10c753c --- /dev/null +++ b/plugins/wasm-go/extensions/ai-proxy/provider/request_helper_test.go @@ -0,0 +1,253 @@ +package provider + +import ( + "encoding/json" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestCleanupContextMessages(t *testing.T) { + t.Run("empty_cleanup_commands", func(t *testing.T) { + body := []byte(`{"messages":[{"role":"user","content":"hello"}]}`) + result, err := cleanupContextMessages(body, []string{}) + assert.NoError(t, err) + assert.Equal(t, body, result) + }) + + t.Run("no_matching_command", func(t *testing.T) { + body := []byte(`{"messages":[{"role":"system","content":"你是助手"},{"role":"user","content":"hello"}]}`) + result, err := cleanupContextMessages(body, []string{"清理上下文", "/clear"}) + assert.NoError(t, err) + assert.Equal(t, body, result) + }) + + t.Run("cleanup_with_single_command", func(t *testing.T) { + input := chatCompletionRequest{ + Messages: []chatMessage{ + {Role: "system", Content: "你是一个助手"}, + {Role: "user", Content: "你好"}, + {Role: "assistant", Content: "你好!"}, + {Role: "user", Content: "清理上下文"}, + {Role: "user", Content: "新问题"}, + }, + } + body, err := json.Marshal(input) + require.NoError(t, err) + + result, err := cleanupContextMessages(body, []string{"清理上下文"}) + assert.NoError(t, err) + + var output chatCompletionRequest + err = json.Unmarshal(result, &output) + require.NoError(t, err) + + assert.Len(t, output.Messages, 2) + assert.Equal(t, "system", output.Messages[0].Role) + assert.Equal(t, "你是一个助手", output.Messages[0].Content) + assert.Equal(t, "user", output.Messages[1].Role) + assert.Equal(t, "新问题", output.Messages[1].Content) + }) + + t.Run("cleanup_with_multiple_commands_match_first", func(t *testing.T) { + input := chatCompletionRequest{ + Messages: []chatMessage{ + {Role: "system", Content: "你是一个助手"}, + {Role: "user", Content: "你好"}, + {Role: "assistant", Content: "你好!"}, + {Role: "user", Content: "/clear"}, + {Role: "user", Content: "新问题"}, + }, + } + body, err := json.Marshal(input) + require.NoError(t, err) + + result, err := cleanupContextMessages(body, []string{"清理上下文", "/clear", "重新开始"}) + assert.NoError(t, err) + + var output chatCompletionRequest + err = json.Unmarshal(result, &output) + require.NoError(t, err) + + assert.Len(t, output.Messages, 2) + assert.Equal(t, "system", output.Messages[0].Role) + assert.Equal(t, "user", output.Messages[1].Role) + assert.Equal(t, "新问题", output.Messages[1].Content) + }) + + t.Run("cleanup_removes_tool_messages", func(t *testing.T) { + input := chatCompletionRequest{ + Messages: []chatMessage{ + {Role: "system", Content: "你是一个助手"}, + {Role: "user", Content: "查天气"}, + {Role: "assistant", Content: ""}, + {Role: "tool", Content: "北京 25°C"}, + {Role: "assistant", Content: "北京今天25度"}, + {Role: "user", Content: "清理上下文"}, + {Role: "user", Content: "新问题"}, + }, + } + body, err := json.Marshal(input) + require.NoError(t, err) + + result, err := cleanupContextMessages(body, []string{"清理上下文"}) + assert.NoError(t, err) + + var output chatCompletionRequest + err = json.Unmarshal(result, &output) + require.NoError(t, err) + + assert.Len(t, output.Messages, 2) + assert.Equal(t, "system", output.Messages[0].Role) + assert.Equal(t, "user", output.Messages[1].Role) + }) + + t.Run("cleanup_keeps_multiple_system_messages", func(t *testing.T) { + input := chatCompletionRequest{ + Messages: []chatMessage{ + {Role: "system", Content: "系统提示1"}, + {Role: "system", Content: "系统提示2"}, + {Role: "user", Content: "你好"}, + {Role: "assistant", Content: "你好!"}, + {Role: "user", Content: "清理上下文"}, + {Role: "user", Content: "新问题"}, + }, + } + body, err := json.Marshal(input) + require.NoError(t, err) + + result, err := cleanupContextMessages(body, []string{"清理上下文"}) + assert.NoError(t, err) + + var output chatCompletionRequest + err = json.Unmarshal(result, &output) + require.NoError(t, err) + + assert.Len(t, output.Messages, 3) + assert.Equal(t, "system", output.Messages[0].Role) + assert.Equal(t, "系统提示1", output.Messages[0].Content) + assert.Equal(t, "system", output.Messages[1].Role) + assert.Equal(t, "系统提示2", output.Messages[1].Content) + assert.Equal(t, "user", output.Messages[2].Role) + }) + + t.Run("cleanup_finds_last_matching_command", func(t *testing.T) { + input := chatCompletionRequest{ + Messages: []chatMessage{ + {Role: "system", Content: "你是一个助手"}, + {Role: "user", Content: "清理上下文"}, + {Role: "user", Content: "中间问题"}, + {Role: "assistant", Content: "中间回答"}, + {Role: "user", Content: "清理上下文"}, + {Role: "user", Content: "最后问题"}, + }, + } + body, err := json.Marshal(input) + require.NoError(t, err) + + result, err := cleanupContextMessages(body, []string{"清理上下文"}) + assert.NoError(t, err) + + var output chatCompletionRequest + err = json.Unmarshal(result, &output) + require.NoError(t, err) + + // 应该匹配最后一个清理命令,保留 system 和 "最后问题" + assert.Len(t, output.Messages, 2) + assert.Equal(t, "system", output.Messages[0].Role) + assert.Equal(t, "user", output.Messages[1].Role) + assert.Equal(t, "最后问题", output.Messages[1].Content) + }) + + t.Run("cleanup_at_end_of_messages", func(t *testing.T) { + input := chatCompletionRequest{ + Messages: []chatMessage{ + {Role: "system", Content: "你是一个助手"}, + {Role: "user", Content: "你好"}, + {Role: "assistant", Content: "你好!"}, + {Role: "user", Content: "清理上下文"}, + }, + } + body, err := json.Marshal(input) + require.NoError(t, err) + + result, err := cleanupContextMessages(body, []string{"清理上下文"}) + assert.NoError(t, err) + + var output chatCompletionRequest + err = json.Unmarshal(result, &output) + require.NoError(t, err) + + // 清理命令在最后,只保留 system + assert.Len(t, output.Messages, 1) + assert.Equal(t, "system", output.Messages[0].Role) + }) + + t.Run("cleanup_without_system_message", func(t *testing.T) { + input := chatCompletionRequest{ + Messages: []chatMessage{ + {Role: "user", Content: "你好"}, + {Role: "assistant", Content: "你好!"}, + {Role: "user", Content: "清理上下文"}, + {Role: "user", Content: "新问题"}, + }, + } + body, err := json.Marshal(input) + require.NoError(t, err) + + result, err := cleanupContextMessages(body, []string{"清理上下文"}) + assert.NoError(t, err) + + var output chatCompletionRequest + err = json.Unmarshal(result, &output) + require.NoError(t, err) + + // 没有 system 消息,只保留清理命令之后的消息 + assert.Len(t, output.Messages, 1) + assert.Equal(t, "user", output.Messages[0].Role) + assert.Equal(t, "新问题", output.Messages[0].Content) + }) + + t.Run("cleanup_with_empty_messages", func(t *testing.T) { + input := chatCompletionRequest{ + Messages: []chatMessage{}, + } + body, err := json.Marshal(input) + require.NoError(t, err) + + result, err := cleanupContextMessages(body, []string{"清理上下文"}) + assert.NoError(t, err) + + var output chatCompletionRequest + err = json.Unmarshal(result, &output) + require.NoError(t, err) + + assert.Len(t, output.Messages, 0) + }) + + t.Run("cleanup_command_partial_match_not_triggered", func(t *testing.T) { + input := chatCompletionRequest{ + Messages: []chatMessage{ + {Role: "system", Content: "你是一个助手"}, + {Role: "user", Content: "请清理上下文吧"}, + {Role: "assistant", Content: "好的"}, + }, + } + body, err := json.Marshal(input) + require.NoError(t, err) + + result, err := cleanupContextMessages(body, []string{"清理上下文"}) + assert.NoError(t, err) + + // 部分匹配不应触发清理 + assert.Equal(t, body, result) + }) + + t.Run("invalid_json_body", func(t *testing.T) { + body := []byte(`invalid json`) + result, err := cleanupContextMessages(body, []string{"清理上下文"}) + assert.Error(t, err) + assert.Equal(t, body, result) + }) +}