Compare commits

...

20 Commits

Author SHA1 Message Date
Jun
a2c2d1d521 fix: fallbackForInvalidSecret to return original secret (#1245) 2024-08-25 15:59:12 +08:00
Yang
a5a28aebf6 Add x-forwarded-xxx for ext-auth (#1244) 2024-08-23 14:49:08 +08:00
YeHaitao
1c10f36369 feat: support 360 ai model (#1243)
Co-authored-by: Kent Dong <ch3cho@qq.com>
2024-08-23 11:13:09 +08:00
韩贤涛
7054f01a36 feat: Adapt to the Qwen multimodal model generation API (#1221) 2024-08-22 18:42:16 +08:00
xingyunyang01
895f17f8d8 update: Add support for post tools, add round limits, per-round token… (#1230)
Co-authored-by: Kent Dong <ch3cho@qq.com>
2024-08-22 16:33:42 +08:00
Pxl
29fcd330d5 feat: support ai-proxy custom settings (#1219) 2024-08-22 13:59:32 +08:00
Yang Beining
0e58042fa6 Support Openai structure output api (#feat 1214) (#1217)
Co-authored-by: Kent Dong <ch3cho@qq.com>
2024-08-22 12:33:35 +08:00
brother-戎
bdbfad8a8a fix: fix up kingress controller NPE (#1235) 2024-08-22 09:59:55 +08:00
ran xuxin
4307f88645 extend ai-prompt-decorator plugin with client's geographic message from geo-ip plugin (#1228) 2024-08-20 16:14:21 +08:00
007gzs
25b085cb5e feat: ai敏感词拦截插件 (#1190) 2024-08-16 17:24:32 +08:00
urlyy
dcea483c61 Feat: Add Deepl support for plugins/ai-proxy (#1147) 2024-08-15 18:53:56 +08:00
rinfx
8fa1224cba support qwen compatible mode (#1205) 2024-08-15 18:52:49 +08:00
xingyunyang01
8f7c10ee5f feat: add ai-agent plugin (#1192) 2024-08-15 17:05:25 +08:00
澄潭
5a854b990b Update README.md 2024-08-15 09:53:02 +08:00
Jingze
dd11248e47 Update README.md (#1203) 2024-08-14 19:55:21 +08:00
mamba
ba98f3a7ad feat: 🎸 frontend-gray plugin support cdn type deploy (#1178)
Co-authored-by: Kent Dong <ch3cho@qq.com>
2024-08-14 15:41:32 +08:00
Jun
d31c978ed3 feat: add AI quota plugin (#1200) 2024-08-14 13:43:31 +08:00
Jingze
daa374d9a4 feat: support wasm-assemblyscript sdk (#1175) 2024-08-13 15:31:36 +08:00
澄潭
6b9dabb489 Update README.md 2024-08-12 19:41:10 +08:00
rinfx
6f04404edd crash bugfix (#1198) 2024-08-12 16:42:10 +08:00
100 changed files with 72703 additions and 410 deletions

View File

@@ -49,6 +49,11 @@ jobs:
with:
go-version: 1.19
- name: Setup Rust
uses: actions-rs/toolchain@v1
with:
toolchain: stable
if: matrix.wasmPluginType == 'RUST'
- name: Setup Golang Caches
uses: actions/cache@v4
with:

View File

@@ -1,8 +1,9 @@
<h1 align="center">
<img src="https://img.alicdn.com/imgextra/i2/O1CN01NwxLDd20nxfGBjxmZ_!!6000000006895-2-tps-960-290.png" alt="Higress" width="240" height="72.5">
<br>
AI Native API Gateway
AI Gateway
</h1>
<h4 align="center"> AI Native API Gateway </h4>
[![Build Status](https://github.com/alibaba/higress/actions/workflows/build-and-test.yaml/badge.svg?branch=main)](https://github.com/alibaba/higress/actions)
[![license](https://img.shields.io/github/license/alibaba/higress.svg)](https://www.apache.org/licenses/LICENSE-2.0.html)
@@ -21,7 +22,7 @@
Higress 是基于阿里内部多年的 Envoy Gateway 实践沉淀,以开源 [Istio](https://github.com/istio/istio) 与 [Envoy](https://github.com/envoyproxy/envoy) 为核心构建的云原生 API 网关。
Higress 是面向 AI 原生设计的 API 网关,在阿里内部,承载了通义千问 APP、百炼大模型 API、机器学习 PAI 平台等 AI 业务的流量。
Higress 在阿里内部作为 AI 网关,承载了通义千问 APP、百炼大模型 API、机器学习 PAI 平台等 AI 业务的流量。
Higress 能够用统一的协议对接国内外所有 LLM 模型厂商,同时具备丰富的 AI 可观测、多模型负载均衡/fallback、AI token 流控、AI 缓存等能力:
@@ -30,13 +31,36 @@ Higress 能够用统一的协议对接国内外所有 LLM 模型厂商,同时
## Summary
- [**快速开始**](#快速开始)
- [**功能展示**](#功能展示)
- [**使用场景**](#使用场景)
- [**核心优势**](#核心优势)
- [**Quick Start**](https://higress.io/zh-cn/docs/user/quickstart)
- [**社区**](#社区)
## 快速开始
Higress 只需 Docker 即可启动,方便个人开发者在本地搭建学习,或者用于搭建简易站点:
```bash
# 创建一个工作目录
mkdir higress; cd higress
# 启动 higress配置文件会写到工作目录下
docker run -d --rm --name higress-ai -v ${PWD}:/data \
-p 8001:8001 -p 8080:8080 -p 8443:8443 \
higress-registry.cn-hangzhou.cr.aliyuncs.com/higress/all-in-one:latest
```
监听端口说明如下:
- 8001 端口Higress UI 控制台入口
- 8080 端口:网关 HTTP 协议入口
- 8443 端口:网关 HTTPS 协议入口
**Higress 的所有 Docker 镜像都一直使用自己独享的仓库,不受 Docker Hub 境内不可访问的影响**
K8s 下使用 Helm 部署等其他安装方式可以参考官网 [Quick Start 文档](https://higress.io/docs/latest/user/quickstart/)。
## 使用场景

View File

@@ -431,11 +431,14 @@ func (c *controller) ConvertGateway(convertOptions *common.ConvertOptions, wrapp
if err != nil {
if k8serrors.IsNotFound(err) {
// If there is no matching secret, try to get it from configmap.
secretName = httpsCredentialConfig.MatchSecretNameByDomain(rule.Host)
secretNamespace = c.options.SystemNamespace
namespace, secret := cert.ParseTLSSecret(secretName)
if namespace != "" {
secretNamespace = namespace
matchSecretName := httpsCredentialConfig.MatchSecretNameByDomain(rule.Host)
if matchSecretName != "" {
namespace, secret := cert.ParseTLSSecret(matchSecretName)
if namespace == "" {
secretNamespace = c.options.SystemNamespace
} else {
secretNamespace = namespace
}
secretName = secret
}
}

View File

@@ -417,11 +417,14 @@ func (c *controller) ConvertGateway(convertOptions *common.ConvertOptions, wrapp
if err != nil {
if k8serrors.IsNotFound(err) {
// If there is no matching secret, try to get it from configmap.
secretName = httpsCredentialConfig.MatchSecretNameByDomain(rule.Host)
secretNamespace = c.options.SystemNamespace
namespace, secret := cert.ParseTLSSecret(secretName)
if namespace != "" {
secretNamespace = namespace
matchSecretName := httpsCredentialConfig.MatchSecretNameByDomain(rule.Host)
if matchSecretName != "" {
namespace, secret := cert.ParseTLSSecret(matchSecretName)
if namespace == "" {
secretNamespace = c.options.SystemNamespace
} else {
secretNamespace = namespace
}
secretName = secret
}
}

View File

@@ -163,7 +163,6 @@ func (c *controller) processNextWorkItem() bool {
func (c *controller) onEvent(namespacedName types.NamespacedName) error {
event := model.EventUpdate
ing, err := c.ingressLister.Ingresses(namespacedName.Namespace).Get(namespacedName.Name)
ing.Status.InitializeConditions()
if err != nil {
if kerrors.IsNotFound(err) {
event = model.EventDelete
@@ -181,6 +180,8 @@ func (c *controller) onEvent(namespacedName types.NamespacedName) error {
return nil
}
ing.Status.InitializeConditions()
// we should check need process only when event is not delete,
// if it is delete event, and previously processed, we need to process too.
if event != model.EventDelete {

View File

@@ -0,0 +1,53 @@
## 介绍
此 SDK 用于使用 AssemblyScript 语言开发 Higress 的 Wasm 插件。
### 如何使用SDK
创建一个新的 AssemblyScript 项目。
```
npm init
npm install --save-dev assemblyscript
npx asinit .
```
在asconfig.json文件中作为传递给asc编译器的选项之一包含"use": "abort=abort_proc_exit"。
```
{
"options": {
"use": "abort=abort_proc_exit"
}
}
```
`"@higress/wasm-assemblyscript": "^0.0.4"`添加到你的依赖项中,然后运行`npm install`
### 本地构建
```
npm run asbuild
```
构建结果将在`build`文件夹中。其中,`debug.wasm``release.wasm`是已编译的文件,在生产环境中建议使用`release.wasm`
注:如果需要插件带有 name section 信息需要带上`"debug": true`,编译参数解释详见[using-the-compiler](https://www.assemblyscript.org/compiler.html#using-the-compiler)。
```json
"release": {
"outFile": "build/release.wasm",
"textFile": "build/release.wat",
"sourceMap": true,
"optimizeLevel": 3,
"shrinkLevel": 0,
"converge": false,
"noAssert": false,
"debug": true
}
```
### AssemblyScript 限制
此 SDK 使用的 AssemblyScript 版本为`0.27.29`,参考[AssemblyScript Status](https://www.assemblyscript.org/status.html)该版本尚未支持闭包、异常、迭代器等特性并且JSON正则表达式等功能还尚未在标准库中实现暂时需要使用社区提供的实现。

View File

@@ -0,0 +1,23 @@
{
"targets": {
"debug": {
"outFile": "build/debug.wasm",
"textFile": "build/debug.wat",
"sourceMap": true,
"debug": true
},
"release": {
"outFile": "build/release.wasm",
"textFile": "build/release.wat",
"sourceMap": true,
"optimizeLevel": 3,
"shrinkLevel": 0,
"converge": false,
"noAssert": false
}
},
"options": {
"bindings": "esm",
"use": "abort=abort_proc_exit"
}
}

View File

@@ -0,0 +1,214 @@
import {
log,
LogLevelValues,
get_property,
WasmResultValues,
} from "@higress/proxy-wasm-assemblyscript-sdk/assembly";
import { getRequestHost } from "./request_wrapper";
export abstract class Cluster {
abstract clusterName(): string;
abstract hostName(): string;
}
export class RouteCluster extends Cluster {
host: string;
constructor(host: string = "") {
super();
this.host = host;
}
clusterName(): string {
let result = get_property("cluster_name");
if (result.status != WasmResultValues.Ok) {
log(LogLevelValues.error, "get route cluster failed");
return "";
}
return String.UTF8.decode(result.returnValue);
}
hostName(): string {
if (this.host != "") {
return this.host;
}
return getRequestHost();
}
}
export class K8sCluster extends Cluster {
serviceName: string;
namespace: string;
port: i64;
version: string;
host: string;
constructor(
serviceName: string,
namespace: string,
port: i64,
version: string = "",
host: string = ""
) {
super();
this.serviceName = serviceName;
this.namespace = namespace;
this.port = port;
this.version = version;
this.host = host;
}
clusterName(): string {
let namespace = this.namespace != "" ? this.namespace : "default";
return `outbound|${this.port}|${this.version}|${this.serviceName}.${namespace}.svc.cluster.local`;
}
hostName(): string {
if (this.host != "") {
return this.host;
}
return `${this.serviceName}.${this.namespace}.svc.cluster.local`;
}
}
export class NacosCluster extends Cluster {
serviceName: string;
group: string;
namespaceID: string;
port: i64;
isExtRegistry: boolean;
version: string;
host: string;
constructor(
serviceName: string,
namespaceID: string,
port: i64,
// use DEFAULT-GROUP by default
group: string = "DEFAULT-GROUP",
// set true if use edas/sae registry
isExtRegistry: boolean = false,
version: string = "",
host: string = ""
) {
super();
this.serviceName = serviceName;
this.group = group.replace("_", "-");
this.namespaceID = namespaceID;
this.port = port;
this.isExtRegistry = isExtRegistry;
this.version = version;
this.host = host;
}
clusterName(): string {
let tail = "nacos" + (this.isExtRegistry ? "-ext" : "");
return `outbound|${this.port}|${this.version}|${this.serviceName}.${this.group}.${this.namespaceID}.${tail}`;
}
hostName(): string {
if (this.host != "") {
return this.host;
}
return this.serviceName;
}
}
export class StaticIpCluster extends Cluster {
serviceName: string;
port: i64;
host: string;
constructor(serviceName: string, port: i64, host: string = "") {
super()
this.serviceName = serviceName;
this.port = port;
this.host = host;
}
clusterName(): string {
return `outbound|${this.port}||${this.serviceName}.static`;
}
hostName(): string {
if (this.host != "") {
return this.host;
}
return this.serviceName;
}
}
export class DnsCluster extends Cluster {
serviceName: string;
domain: string;
port: i64;
constructor(serviceName: string, domain: string, port: i64) {
super();
this.serviceName = serviceName;
this.domain = domain;
this.port = port;
}
clusterName(): string {
return `outbound|${this.port}||${this.serviceName}.dns`;
}
hostName(): string {
return this.domain;
}
}
export class ConsulCluster extends Cluster {
serviceName: string;
datacenter: string;
port: i64;
host: string;
constructor(
serviceName: string,
datacenter: string,
port: i64,
host: string = ""
) {
super();
this.serviceName = serviceName;
this.datacenter = datacenter;
this.port = port;
this.host = host;
}
clusterName(): string {
return `outbound|${this.port}||${this.serviceName}.${this.datacenter}.consul`;
}
hostName(): string {
if (this.host != "") {
return this.host;
}
return this.serviceName;
}
}
export class FQDNCluster extends Cluster {
fqdn: string;
host: string;
port: i64;
constructor(fqdn: string, port: i64, host: string = "") {
super();
this.fqdn = fqdn;
this.host = host;
this.port = port;
}
clusterName(): string {
return `outbound|${this.port}||${this.fqdn}`;
}
hostName(): string {
if (this.host != "") {
return this.host;
}
return this.fqdn;
}
}

View File

@@ -0,0 +1,120 @@
import {
Cluster
} from "./cluster_wrapper"
import {
log,
LogLevelValues,
Headers,
HeaderPair,
root_context,
BufferTypeValues,
get_buffer_bytes,
BaseContext,
stream_context,
WasmResultValues,
RootContext,
ResponseCallBack
} from "@higress/proxy-wasm-assemblyscript-sdk/assembly";
export interface HttpClient {
get(path: string, headers: Headers, cb: ResponseCallBack, timeoutMillisecond: u32): boolean;
head(path: string, headers: Headers, cb: ResponseCallBack, timeoutMillisecond: u32): boolean;
options(path: string, headers: Headers, cb: ResponseCallBack, timeoutMillisecond: u32): boolean;
post(path: string, headers: Headers, body: ArrayBuffer, cb: ResponseCallBack, timeoutMillisecond: u32): boolean;
put(path: string, headers: Headers, body: ArrayBuffer, cb: ResponseCallBack, timeoutMillisecond: u32): boolean;
patch(path: string, headers: Headers, body: ArrayBuffer, cb: ResponseCallBack, timeoutMillisecond: u32): boolean;
delete(path: string, headers: Headers, body: ArrayBuffer, cb: ResponseCallBack, timeoutMillisecond: u32): boolean;
connect(path: string, headers: Headers, body: ArrayBuffer, cb: ResponseCallBack, timeoutMillisecond: u32): boolean;
trace(path: string, headers: Headers, body: ArrayBuffer, cb: ResponseCallBack, timeoutMillisecond: u32): boolean;
}
const methodArrayBuffer: ArrayBuffer = String.UTF8.encode(":method");
const pathArrayBuffer: ArrayBuffer = String.UTF8.encode(":path");
const authorityArrayBuffer: ArrayBuffer = String.UTF8.encode(":authority");
const StatusBadGateway: i32 = 502;
export class ClusterClient {
cluster: Cluster;
constructor(cluster: Cluster) {
this.cluster = cluster;
}
private httpCall(method: string, path: string, headers: Headers, body: ArrayBuffer, callback: ResponseCallBack, timeoutMillisecond: u32 = 500): boolean {
if (root_context == null) {
log(LogLevelValues.error, "Root context is null");
return false;
}
for (let i: i32 = headers.length - 1; i >= 0; i--) {
const key = String.UTF8.decode(headers[i].key)
if ((key == ":method") || (key == ":path") || (key == ":authority")) {
headers.splice(i, 1);
}
}
headers.push(new HeaderPair(methodArrayBuffer, String.UTF8.encode(method)));
headers.push(new HeaderPair(pathArrayBuffer, String.UTF8.encode(path)));
headers.push(new HeaderPair(authorityArrayBuffer, String.UTF8.encode(this.cluster.hostName())));
const result = (root_context as RootContext).httpCall(this.cluster.clusterName(), headers, body, [], timeoutMillisecond, root_context as BaseContext, callback,
(_origin_context: BaseContext, _numHeaders: u32, body_size: usize, _trailers: u32, callback: ResponseCallBack): void => {
const respBody = get_buffer_bytes(BufferTypeValues.HttpCallResponseBody, 0, body_size as u32);
const respHeaders = stream_context.headers.http_callback.get_headers()
let code = StatusBadGateway;
let headers = new Array<HeaderPair>();
for (let i = 0; i < respHeaders.length; i++) {
const h = respHeaders[i];
if (String.UTF8.decode(h.key) == ":status") {
code = <i32>parseInt(String.UTF8.decode(h.value))
}
headers.push(new HeaderPair(h.key, h.value));
}
log(LogLevelValues.debug, `http call end, code: ${code}, body: ${String.UTF8.decode(respBody)}`)
callback(code, headers, respBody);
})
log(LogLevelValues.debug, `http call start, cluster: ${this.cluster.clusterName()}, method: ${method}, path: ${path}, body: ${String.UTF8.decode(body)}, timeout: ${timeoutMillisecond}`)
if (result != WasmResultValues.Ok) {
log(LogLevelValues.error, `http call failed, result: ${result}`)
return false
}
return true
}
get(path: string, headers: Headers, cb: ResponseCallBack, timeoutMillisecond: u32 = 500): boolean {
return this.httpCall("GET", path, headers, new ArrayBuffer(0), cb, timeoutMillisecond);
}
head(path: string, headers: Headers, cb: ResponseCallBack, timeoutMillisecond: u32 = 500): boolean {
return this.httpCall("HEAD", path, headers, new ArrayBuffer(0), cb, timeoutMillisecond);
}
options(path: string, headers: Headers, cb: ResponseCallBack, timeoutMillisecond: u32 = 500): boolean {
return this.httpCall("OPTIONS", path, headers, new ArrayBuffer(0), cb, timeoutMillisecond);
}
post(path: string, headers: Headers, body: ArrayBuffer, cb: ResponseCallBack, timeoutMillisecond: u32 = 500): boolean {
return this.httpCall("POST", path, headers, body, cb, timeoutMillisecond);
}
put(path: string, headers: Headers, body: ArrayBuffer, cb: ResponseCallBack, timeoutMillisecond: u32 = 500): boolean {
return this.httpCall("PUT", path, headers, body, cb, timeoutMillisecond);
}
patch(path: string, headers: Headers, body: ArrayBuffer, cb: ResponseCallBack, timeoutMillisecond: u32 = 500): boolean {
return this.httpCall("PATCH", path, headers, body, cb, timeoutMillisecond);
}
delete(path: string, headers: Headers, body: ArrayBuffer, cb: ResponseCallBack, timeoutMillisecond: u32 = 500): boolean {
return this.httpCall("DELETE", path, headers, body, cb, timeoutMillisecond);
}
connect(path: string, headers: Headers, body: ArrayBuffer, cb: ResponseCallBack, timeoutMillisecond: u32 = 500): boolean {
return this.httpCall("CONNECT", path, headers, body, cb, timeoutMillisecond);
}
trace(path: string, headers: Headers, body: ArrayBuffer, cb: ResponseCallBack, timeoutMillisecond: u32 = 500): boolean {
return this.httpCall("TRACE", path, headers, body, cb, timeoutMillisecond);
}
}

View File

@@ -0,0 +1,18 @@
export {RouteCluster,
K8sCluster,
NacosCluster,
ConsulCluster,
FQDNCluster,
StaticIpCluster} from "./cluster_wrapper"
export {HttpClient,
ClusterClient} from "./http_wrapper"
export {Log} from "./log_wrapper"
export {SetCtx,
HttpContext,
ParseConfigBy,
ProcessRequestBodyBy,
ProcessRequestHeadersBy,
ProcessResponseBodyBy,
ProcessResponseHeadersBy,
Logger, RegisteTickFunc} from "./plugin_wrapper"
export {ParseResult} from "./rule_matcher"

View File

@@ -0,0 +1,66 @@
import { log, LogLevelValues } from "@higress/proxy-wasm-assemblyscript-sdk/assembly";
enum LogLevel {
Trace = 0,
Debug,
Info,
Warn,
Error,
Critical,
}
export class Log {
private pluginName: string;
constructor(pluginName: string) {
this.pluginName = pluginName;
}
private log(level: LogLevel, msg: string): void {
let formattedMsg = `[${this.pluginName}] ${msg}`;
switch (level) {
case LogLevel.Trace:
log(LogLevelValues.trace, formattedMsg);
break;
case LogLevel.Debug:
log(LogLevelValues.debug, formattedMsg);
break;
case LogLevel.Info:
log(LogLevelValues.info, formattedMsg);
break;
case LogLevel.Warn:
log(LogLevelValues.warn, formattedMsg);
break;
case LogLevel.Error:
log(LogLevelValues.error, formattedMsg);
break;
case LogLevel.Critical:
log(LogLevelValues.critical, formattedMsg);
break;
}
}
public Trace(msg: string): void {
this.log(LogLevel.Trace, msg);
}
public Debug(msg: string): void {
this.log(LogLevel.Debug, msg);
}
public Info(msg: string): void {
this.log(LogLevel.Info, msg);
}
public Warn(msg: string): void {
this.log(LogLevel.Warn, msg);
}
public Error(msg: string): void {
this.log(LogLevel.Error, msg);
}
public Critical(msg: string): void {
this.log(LogLevel.Critical, msg);
}
}

View File

@@ -0,0 +1,445 @@
import { Log } from "./log_wrapper";
import {
Context,
FilterHeadersStatusValues,
RootContext,
setRootContext,
proxy_set_effective_context,
log,
LogLevelValues,
FilterDataStatusValues,
get_buffer_bytes,
BufferTypeValues,
set_tick_period_milliseconds,
get_current_time_nanoseconds
} from "@higress/proxy-wasm-assemblyscript-sdk/assembly";
import {
getRequestHost,
getRequestMethod,
getRequestPath,
getRequestScheme,
isBinaryRequestBody,
} from "./request_wrapper";
import { RuleMatcher, ParseResult } from "./rule_matcher";
import { JSON } from "assemblyscript-json/assembly";
export function SetCtx<PluginConfig>(
pluginName: string,
setFuncs: usize[] = []
): void {
const rootContextId = 1
setRootContext(new CommonRootCtx<PluginConfig>(rootContextId, pluginName, setFuncs));
}
export interface HttpContext {
Scheme(): string;
Host(): string;
Path(): string;
Method(): string;
SetContext(key: string, value: usize): void;
GetContext(key: string): usize;
DontReadRequestBody(): void;
DontReadResponseBody(): void;
}
type ParseConfigFunc<PluginConfig> = (
json: JSON.Obj,
) => ParseResult<PluginConfig>;
type OnHttpHeadersFunc<PluginConfig> = (
context: HttpContext,
config: PluginConfig,
) => FilterHeadersStatusValues;
type OnHttpBodyFunc<PluginConfig> = (
context: HttpContext,
config: PluginConfig,
body: ArrayBuffer,
) => FilterDataStatusValues;
export var Logger: Log = new Log("");
class CommonRootCtx<PluginConfig> extends RootContext {
pluginName: string;
hasCustomConfig: boolean;
ruleMatcher: RuleMatcher<PluginConfig>;
parseConfig: ParseConfigFunc<PluginConfig> | null;
onHttpRequestHeaders: OnHttpHeadersFunc<PluginConfig> | null;
onHttpRequestBody: OnHttpBodyFunc<PluginConfig> | null;
onHttpResponseHeaders: OnHttpHeadersFunc<PluginConfig> | null;
onHttpResponseBody: OnHttpBodyFunc<PluginConfig> | null;
onTickFuncs: Array<TickFuncEntry>;
constructor(context_id: u32, pluginName: string, setFuncs: usize[]) {
super(context_id);
this.pluginName = pluginName;
Logger = new Log(pluginName);
this.hasCustomConfig = true;
this.onHttpRequestHeaders = null;
this.onHttpRequestBody = null;
this.onHttpResponseHeaders = null;
this.onHttpResponseBody = null;
this.parseConfig = null;
this.ruleMatcher = new RuleMatcher<PluginConfig>();
this.onTickFuncs = new Array<TickFuncEntry>();
for (let i = 0; i < setFuncs.length; i++) {
changetype<Closure<PluginConfig>>(setFuncs[i]).lambdaFn(
setFuncs[i],
this
);
}
if (this.parseConfig == null) {
this.hasCustomConfig = false;
this.parseConfig = (json: JSON.Obj): ParseResult<PluginConfig> =>{ return new ParseResult<PluginConfig>(null, true); };
}
}
createContext(context_id: u32): Context {
return new CommonCtx<PluginConfig>(context_id, this);
}
onConfigure(configuration_size: u32): boolean {
super.onConfigure(configuration_size);
const data = this.getConfiguration();
let jsonData: JSON.Obj = new JSON.Obj();
if (data == "{}") {
if (this.hasCustomConfig) {
log(LogLevelValues.warn, "config is empty, but has ParseConfigFunc");
}
} else {
const parseData = JSON.parse(data);
if (parseData.isObj) {
jsonData = changetype<JSON.Obj>(JSON.parse(data));
} else {
log(LogLevelValues.error, "parse json data failed")
return false;
}
}
if (!this.ruleMatcher.parseRuleConfig(jsonData, this.parseConfig as ParseConfigFunc<PluginConfig>)) {
return false;
}
if (globalOnTickFuncs.length > 0) {
this.onTickFuncs = globalOnTickFuncs;
set_tick_period_milliseconds(100);
}
return true;
}
onTick(): void {
for (let i = 0; i < this.onTickFuncs.length; i++) {
const tickFuncEntry = this.onTickFuncs[i];
const now = getCurrentTimeMilliseconds();
if (tickFuncEntry.lastExecuted + tickFuncEntry.tickPeriod <= now) {
tickFuncEntry.tickFunc();
tickFuncEntry.lastExecuted = getCurrentTimeMilliseconds();
}
}
}
}
function getCurrentTimeMilliseconds(): u64 {
return get_current_time_nanoseconds() / 1000000;
}
class TickFuncEntry {
lastExecuted: u64;
tickPeriod: u64;
tickFunc: () => void;
constructor(lastExecuted: u64, tickPeriod: u64, tickFunc: () => void) {
this.lastExecuted = lastExecuted;
this.tickPeriod = tickPeriod;
this.tickFunc = tickFunc;
}
}
var globalOnTickFuncs = new Array<TickFuncEntry>();
export function RegisteTickFunc(tickPeriod: i64, tickFunc: () => void): void {
globalOnTickFuncs.push(new TickFuncEntry(0, tickPeriod, tickFunc));
}
class Closure<PluginConfig> {
lambdaFn: (closure: usize, ctx: CommonRootCtx<PluginConfig>) => void;
parseConfigFunc: ParseConfigFunc<PluginConfig> | null;
onHttpHeadersFunc: OnHttpHeadersFunc<PluginConfig> | null;
OnHttpBodyFunc: OnHttpBodyFunc<PluginConfig> | null;
constructor(
lambdaFn: (closure: usize, ctx: CommonRootCtx<PluginConfig>) => void
) {
this.lambdaFn = lambdaFn;
this.parseConfigFunc = null;
this.onHttpHeadersFunc = null;
this.OnHttpBodyFunc = null;
}
setParseConfigFunc(f: ParseConfigFunc<PluginConfig>): void {
this.parseConfigFunc = f;
}
setHttpHeadersFunc(f: OnHttpHeadersFunc<PluginConfig>): void {
this.onHttpHeadersFunc = f;
}
setHttpBodyFunc(f: OnHttpBodyFunc<PluginConfig>): void {
this.OnHttpBodyFunc = f;
}
}
export function ParseConfigBy<PluginConfig>(
f: ParseConfigFunc<PluginConfig>
): usize {
const lambdaFn = function (
closure: usize,
ctx: CommonRootCtx<PluginConfig>
): void {
const f = changetype<Closure<PluginConfig>>(closure).parseConfigFunc;
if (f != null) {
ctx.parseConfig = f;
}
};
const closure = new Closure<PluginConfig>(lambdaFn);
closure.setParseConfigFunc(f);
return changetype<usize>(closure);
}
export function ProcessRequestHeadersBy<PluginConfig>(
f: OnHttpHeadersFunc<PluginConfig>
): usize {
const lambdaFn = function (
closure: usize,
ctx: CommonRootCtx<PluginConfig>
): void {
const f = changetype<Closure<PluginConfig>>(closure).onHttpHeadersFunc;
if (f != null) {
ctx.onHttpRequestHeaders = f;
}
};
const closure = new Closure<PluginConfig>(lambdaFn);
closure.setHttpHeadersFunc(f);
return changetype<usize>(closure);
}
export function ProcessRequestBodyBy<PluginConfig>(
f: OnHttpBodyFunc<PluginConfig>
): usize {
const lambdaFn = function (
closure: usize,
ctx: CommonRootCtx<PluginConfig>
): void {
const f = changetype<Closure<PluginConfig>>(closure).OnHttpBodyFunc;
if (f != null) {
ctx.onHttpRequestBody = f;
}
};
const closure = new Closure<PluginConfig>(lambdaFn);
closure.setHttpBodyFunc(f);
return changetype<usize>(closure);
}
export function ProcessResponseHeadersBy<PluginConfig>(
f: OnHttpHeadersFunc<PluginConfig>
): usize {
const lambdaFn = function (
closure: usize,
ctx: CommonRootCtx<PluginConfig>
): void {
const f = changetype<Closure<PluginConfig>>(closure).onHttpHeadersFunc;
if (f != null) {
ctx.onHttpResponseHeaders = f;
}
};
const closure = new Closure<PluginConfig>(lambdaFn);
closure.setHttpHeadersFunc(f);
return changetype<usize>(closure);
}
export function ProcessResponseBodyBy<PluginConfig>(
f: OnHttpBodyFunc<PluginConfig>
): usize {
const lambdaFn = function (
closure: usize,
ctx: CommonRootCtx<PluginConfig>
): void {
const f = changetype<Closure<PluginConfig>>(closure).OnHttpBodyFunc;
if (f != null) {
ctx.onHttpResponseBody = f;
}
};
const closure = new Closure<PluginConfig>(lambdaFn);
closure.setHttpBodyFunc(f);
return changetype<usize>(closure);
}
class CommonCtx<PluginConfig> extends Context implements HttpContext {
commonRootCtx: CommonRootCtx<PluginConfig>;
config: PluginConfig |null;
needRequestBody: boolean;
needResponseBody: boolean;
requestBodySize: u32;
responseBodySize: u32;
contextID: u32;
userContext: Map<string, usize>;
constructor(context_id: u32, root_context: CommonRootCtx<PluginConfig>) {
super(context_id, root_context);
this.userContext = new Map<string, usize>();
this.commonRootCtx = root_context;
this.contextID = context_id;
this.requestBodySize = 0;
this.responseBodySize = 0;
this.config = null
if (this.commonRootCtx.onHttpRequestHeaders != null) {
this.needResponseBody = true;
} else {
this.needResponseBody = false;
}
if (this.commonRootCtx.onHttpRequestBody != null) {
this.needRequestBody = true;
} else {
this.needRequestBody = false;
}
}
SetContext(key: string, value: usize): void {
this.userContext.set(key, value);
}
GetContext(key: string): usize {
return this.userContext.get(key);
}
Scheme(): string {
proxy_set_effective_context(this.contextID);
return getRequestScheme();
}
Host(): string {
proxy_set_effective_context(this.contextID);
return getRequestHost();
}
Path(): string {
proxy_set_effective_context(this.contextID);
return getRequestPath();
}
Method(): string {
proxy_set_effective_context(this.contextID);
return getRequestMethod();
}
DontReadRequestBody(): void {
this.needRequestBody = false;
}
DontReadResponseBody(): void {
this.needResponseBody = false;
}
onRequestHeaders(_a: u32, _end_of_stream: boolean): FilterHeadersStatusValues {
const parseResult = this.commonRootCtx.ruleMatcher.getMatchConfig();
if (parseResult.success == false) {
log(LogLevelValues.error, "get match config failed");
return FilterHeadersStatusValues.Continue;
}
this.config = parseResult.pluginConfig;
if (isBinaryRequestBody()) {
this.needRequestBody = false;
}
if (this.commonRootCtx.onHttpRequestHeaders == null) {
return FilterHeadersStatusValues.Continue;
}
return this.commonRootCtx.onHttpRequestHeaders(
this,
this.config as PluginConfig
);
}
onRequestBody(
body_buffer_length: usize,
end_of_stream: boolean
): FilterDataStatusValues {
if (this.config == null || !this.needRequestBody) {
return FilterDataStatusValues.Continue;
}
if (this.commonRootCtx.onHttpRequestBody == null) {
return FilterDataStatusValues.Continue;
}
this.requestBodySize += body_buffer_length as u32;
if (!end_of_stream) {
return FilterDataStatusValues.StopIterationAndBuffer;
}
const body = get_buffer_bytes(
BufferTypeValues.HttpRequestBody,
0,
this.requestBodySize
);
return this.commonRootCtx.onHttpRequestBody(
this,
this.config as PluginConfig,
body
);
}
onResponseHeaders(_a: u32, _end_of_stream: bool): FilterHeadersStatusValues {
if (this.config == null) {
return FilterHeadersStatusValues.Continue;
}
if (isBinaryRequestBody()) {
this.needResponseBody = false;
}
if (this.commonRootCtx.onHttpResponseHeaders == null) {
return FilterHeadersStatusValues.Continue;
}
return this.commonRootCtx.onHttpResponseHeaders(
this,
this.config as PluginConfig
);
}
onResponseBody(
body_buffer_length: usize,
end_of_stream: bool
): FilterDataStatusValues {
if (this.config == null) {
return FilterDataStatusValues.Continue;
}
if (this.commonRootCtx.onHttpResponseBody == null) {
return FilterDataStatusValues.Continue;
}
if (!this.needResponseBody) {
return FilterDataStatusValues.Continue;
}
this.responseBodySize += body_buffer_length as u32;
if (!end_of_stream) {
return FilterDataStatusValues.StopIterationAndBuffer;
}
const body = get_buffer_bytes(
BufferTypeValues.HttpResponseBody,
0,
this.responseBodySize
);
return this.commonRootCtx.onHttpResponseBody(
this,
this.config as PluginConfig,
body
);
}
}

View File

@@ -0,0 +1,65 @@
import {
stream_context,
log,
LogLevelValues
} from "@higress/proxy-wasm-assemblyscript-sdk/assembly";
export function getRequestScheme(): string {
let scheme: string = stream_context.headers.request.get(":scheme");
if (scheme == "") {
log(LogLevelValues.error, "Parse request scheme failed");
}
return scheme;
}
export function getRequestHost(): string {
let host: string = stream_context.headers.request.get(":authority");
if (host == "") {
log(LogLevelValues.error, "Parse request host failed");
}
return host;
}
export function getRequestPath(): string {
let path: string = stream_context.headers.request.get(":path");
if (path == "") {
log(LogLevelValues.error, "Parse request path failed");
}
return path;
}
export function getRequestMethod(): string {
let method: string = stream_context.headers.request.get(":method");
if (method == "") {
log(LogLevelValues.error, "Parse request method failed");
}
return method;
}
export function isBinaryRequestBody(): boolean {
let contentType: string = stream_context.headers.request.get("content-type");
if (contentType != "" && (contentType.includes("octet-stream") || contentType.includes("grpc"))) {
return true;
}
let encoding: string = stream_context.headers.request.get("content-encoding");
if (encoding != "") {
return true;
}
return false;
}
export function isBinaryResponseBody(): boolean {
let contentType: string = stream_context.headers.response.get("content-type");
if (contentType != "" && (contentType.includes("octet-stream") || contentType.includes("grpc"))) {
return true;
}
let encoding: string = stream_context.headers.response.get("content-encoding");
if (encoding != "") {
return true;
}
return false;
}

View File

@@ -0,0 +1,346 @@
import { getRequestHost } from "./request_wrapper";
import {
get_property,
LogLevelValues,
log,
WasmResultValues,
} from "@higress/proxy-wasm-assemblyscript-sdk/assembly";
import { JSON } from "assemblyscript-json/assembly";
enum Category {
Route,
Host,
RoutePrefix,
Service
}
enum MatchType {
Prefix,
Exact,
Suffix,
}
const RULES_KEY: string = "_rules_";
const MATCH_ROUTE_KEY: string = "_match_route_";
const MATCH_DOMAIN_KEY: string = "_match_domain_";
const MATCH_SERVICE_KEY: string = "_match_service_";
const MATCH_ROUTE_PREFIX_KEY: string = "_match_route_prefix_"
class HostMatcher {
matchType: MatchType;
host: string;
constructor(matchType: MatchType, host: string) {
this.matchType = matchType;
this.host = host;
}
}
class RuleConfig<PluginConfig> {
category: Category;
routes!: Map<string, boolean>;
services!: Map<string, boolean>;
routePrefixs!: Map<string, boolean>;
hosts!: Array<HostMatcher>;
config: PluginConfig | null;
constructor() {
this.category = Category.Route;
this.config = null;
}
}
export class ParseResult<PluginConfig> {
pluginConfig: PluginConfig | null;
success: boolean;
constructor(pluginConfig: PluginConfig | null, success: boolean) {
this.pluginConfig = pluginConfig;
this.success = success;
}
}
export class RuleMatcher<PluginConfig> {
ruleConfig: Array<RuleConfig<PluginConfig>>;
globalConfig: PluginConfig | null;
hasGlobalConfig: boolean;
constructor() {
this.ruleConfig = new Array<RuleConfig<PluginConfig>>();
this.globalConfig = null;
this.hasGlobalConfig = false;
}
getMatchConfig(): ParseResult<PluginConfig> {
const host = getRequestHost();
if (host == "") {
return new ParseResult<PluginConfig>(null, false);
}
let result = get_property("route_name");
if (result.status != WasmResultValues.Ok && result.status != WasmResultValues.NotFound) {
return new ParseResult<PluginConfig>(null, false);
}
const routeName = String.UTF8.decode(result.returnValue);
result = get_property("cluster_name");
if (result.status != WasmResultValues.Ok && result.status != WasmResultValues.NotFound) {
return new ParseResult<PluginConfig>(null, false);
}
const serviceName = String.UTF8.decode(result.returnValue);
for (let i = 0; i < this.ruleConfig.length; i++) {
const rule = this.ruleConfig[i];
// category == Host
if (rule.category == Category.Host) {
if (this.hostMatch(rule, host)) {
log(LogLevelValues.debug, "getMatchConfig: match host " + host);
return new ParseResult<PluginConfig>(rule.config, true);
}
}
// category == Route
if (rule.category == Category.Route) {
if (rule.routes.has(routeName)) {
log(LogLevelValues.debug, "getMatchConfig: match route " + routeName);
return new ParseResult<PluginConfig>(rule.config, true);
}
}
// category == RoutePrefix
if (rule.category == Category.RoutePrefix) {
for (let i = 0; i < rule.routePrefixs.keys().length; i++) {
const routePrefix = rule.routePrefixs.keys()[i];
if (routeName.startsWith(routePrefix)) {
return new ParseResult<PluginConfig>(rule.config, true);
}
}
}
// category == Cluster
if (this.serviceMatch(rule, serviceName)) {
return new ParseResult<PluginConfig>(rule.config, true);
}
}
if (this.hasGlobalConfig) {
return new ParseResult<PluginConfig>(this.globalConfig, true);
}
return new ParseResult<PluginConfig>(null, false);
}
parseRuleConfig(
config: JSON.Obj,
parsePluginConfig: (json: JSON.Obj) => ParseResult<PluginConfig>
): boolean {
const obj = config;
let keyCount = obj.keys.length;
if (keyCount == 0) {
this.hasGlobalConfig = true;
const parseResult = parsePluginConfig(config);
if (parseResult.success) {
this.globalConfig = parseResult.pluginConfig;
return true;
} else {
return false;
}
}
let rules: JSON.Arr | null = null;
if (obj.has(RULES_KEY)) {
rules = obj.getArr(RULES_KEY);
keyCount--;
}
if (keyCount > 0) {
const parseResult = parsePluginConfig(config);
if (parseResult.success) {
this.globalConfig = parseResult.pluginConfig;
this.hasGlobalConfig = true;
}
}
if (!rules) {
if (this.hasGlobalConfig) {
return true;
}
log(LogLevelValues.error, "parse config failed, no valid rules; global config parse error");
return false;
}
const rulesArray = rules.valueOf();
for (let i = 0; i < rulesArray.length; i++) {
if (!rulesArray[i].isObj) {
log(LogLevelValues.error, "parse rule failed, rules must be an array of objects");
continue;
}
const ruleJson = changetype<JSON.Obj>(rulesArray[i]);
const rule = new RuleConfig<PluginConfig>();
const parseResult = parsePluginConfig(ruleJson);
if (parseResult.success) {
rule.config = parseResult.pluginConfig;
} else {
return false;
}
rule.routes = this.parseRouteMatchConfig(ruleJson);
rule.hosts = this.parseHostMatchConfig(ruleJson);
rule.services = this.parseServiceMatchConfig(ruleJson);
rule.routePrefixs = this.parseRoutePrefixMatchConfig(ruleJson);
const noRoute = rule.routes.size == 0;
const noHosts = rule.hosts.length == 0;
const noServices = rule.services.size == 0;
const noRoutePrefixs = rule.routePrefixs.size == 0;
if ((boolToInt(noRoute) + boolToInt(noHosts) + boolToInt(noServices) + boolToInt(noRoutePrefixs)) != 3) {
log(LogLevelValues.error, "there is only one of '_match_route_', '_match_domain_', '_match_service_' and '_match_route_prefix_' can present in configuration.");
return false;
}
if (!noRoute) {
rule.category = Category.Route;
} else if (!noHosts) {
rule.category = Category.Host;
} else if (!noServices) {
rule.category = Category.Service;
} else {
rule.category = Category.RoutePrefix;
}
this.ruleConfig.push(rule);
}
return true;
}
parseRouteMatchConfig(config: JSON.Obj): Map<string, boolean> {
const keys = config.getArr(MATCH_ROUTE_KEY);
const routes = new Map<string, boolean>();
if (keys) {
const array = keys.valueOf();
for (let i = 0; i < array.length; i++) {
const key = array[i].toString();
if (key != "") {
routes.set(key, true);
}
}
}
return routes;
}
parseRoutePrefixMatchConfig(config: JSON.Obj): Map<string, boolean> {
const keys = config.getArr(MATCH_ROUTE_PREFIX_KEY);
const routePrefixs = new Map<string, boolean>();
if (keys) {
const array = keys.valueOf();
for (let i = 0; i < array.length; i++) {
const key = array[i].toString();
if (key != "") {
routePrefixs.set(key, true);
}
}
}
return routePrefixs;
}
parseServiceMatchConfig(config: JSON.Obj): Map<string, boolean> {
const keys = config.getArr(MATCH_SERVICE_KEY);
const clusters = new Map<string, boolean>();
if (keys) {
const array = keys.valueOf();
for (let i = 0; i < array.length; i++) {
const key = array[i].toString();
if (key != "") {
clusters.set(key, true);
}
}
}
return clusters;
}
parseHostMatchConfig(config: JSON.Obj): Array<HostMatcher> {
const hostMatchers = new Array<HostMatcher>();
const keys = config.getArr(MATCH_DOMAIN_KEY);
if (keys !== null) {
const array = keys.valueOf();
for (let i = 0; i < array.length; i++) {
const item = array[i].toString(); // Assuming the array has string elements
let hostMatcher: HostMatcher;
if (item.startsWith("*")) {
hostMatcher = new HostMatcher(MatchType.Suffix, item.substr(1));
} else if (item.endsWith("*")) {
hostMatcher = new HostMatcher(
MatchType.Prefix,
item.substr(0, item.length - 1)
);
} else {
hostMatcher = new HostMatcher(MatchType.Exact, item);
}
hostMatchers.push(hostMatcher);
}
}
return hostMatchers;
}
stripPortFromHost(reqHost: string): string {
// Port removing code is inspired by
// https://github.com/envoyproxy/envoy/blob/v1.17.0/source/common/http/header_utility.cc#L219
let portStart: i32 = reqHost.lastIndexOf(":");
if (portStart != -1) {
// According to RFC3986 v6 address is always enclosed in "[]".
// section 3.2.2.
let v6EndIndex: i32 = reqHost.lastIndexOf("]");
if (v6EndIndex == -1 || v6EndIndex < portStart) {
if (portStart + 1 <= reqHost.length) {
return reqHost.substring(0, portStart);
}
}
}
return reqHost;
}
hostMatch(rule: RuleConfig<PluginConfig>, reqHost: string): boolean {
reqHost = this.stripPortFromHost(reqHost);
for (let i = 0; i < rule.hosts.length; i++) {
let hostMatch = rule.hosts[i];
switch (hostMatch.matchType) {
case MatchType.Suffix:
if (reqHost.endsWith(hostMatch.host)) {
return true;
}
break;
case MatchType.Prefix:
if (reqHost.startsWith(hostMatch.host)) {
return true;
}
break;
case MatchType.Exact:
if (reqHost == hostMatch.host) {
return true;
}
break;
default:
return false;
}
}
return false;
}
serviceMatch(rule: RuleConfig<PluginConfig>, serviceName: string): boolean {
const parts = serviceName.split('|');
if (parts.length != 4) {
return false;
}
const port = parts[1];
const fqdn = parts[3];
for (let i = 0; i < rule.services.keys().length; i++) {
let configServiceName = rule.services.keys()[i];
let colonIndex = configServiceName.lastIndexOf(':');
if (colonIndex != -1) {
let configFQDN = configServiceName.slice(0, colonIndex);
let configPort = configServiceName.slice(colonIndex + 1);
if (fqdn == configFQDN && port == configPort) return true;
} else if (fqdn == configServiceName) {
return true;
}
}
return false;
}
}
function boolToInt(value: boolean): i32 {
return value ? 1 : 0;
}

View File

@@ -0,0 +1,6 @@
{
"extends": "assemblyscript/std/assembly.json",
"include": [
"./**/*.ts"
]
}

View File

@@ -0,0 +1,80 @@
# 功能说明
`custom-response`插件支持配置自定义的响应,包括自定义 HTTP 应答状态码、HTTP 应答头,以及 HTTP 应答 Body。可以用于 Mock 响应,也可以用于判断特定状态码后给出自定义应答,例如在触发网关限流策略时实现自定义响应。
# 配置字段
| 名称 | 数据类型 | 填写要求 | 默认值 | 描述 |
| -------- | -------- | -------- | -------- | -------- |
| status_code | number | 选填 | 200 | 自定义 HTTP 应答状态码 |
| headers | array of string | 选填 | - | 自定义 HTTP 应答头key 和 value 用`=`分隔 |
| body | string | 选填 | - | 自定义 HTTP 应答 Body |
| enable_on_status | array of number | 选填 | - | 匹配原始状态码,生成自定义响应,不填写时,不判断原始状态码 |
# 配置示例
## Mock 应答场景
```yaml
status_code: 200
headers:
- Content-Type=application/json
- Hello=World
body: "{\"hello\":\"world\"}"
```
根据该配置,请求将返回自定义应答如下:
```text
HTTP/1.1 200 OK
Content-Type: application/json
Hello: World
Content-Length: 17
{"hello":"world"}
```
## 触发限流时自定义响应
```yaml
enable_on_status:
- 429
status_code: 302
headers:
- Location=https://example.com
```
触发网关限流时一般会返回 `429` 状态码,这时请求将返回自定义应答如下:
```text
HTTP/1.1 302 Found
Location: https://example.com
```
从而实现基于浏览器 302 重定向机制,将限流后的用户引导到其他页面,比如可以是一个 CDN 上的静态页面。
如果希望触发限流时,正常返回其他应答,参考 Mock 应答场景配置相应的字段即可。
## 对特定路由或域名开启
```yaml
# 使用 matchRules 字段进行细粒度规则配置
matchRules:
# 规则一:按 Ingress 名称匹配生效
- ingress:
- default/foo
- default/bar
body: "{\"hello\":\"world\"}"
# 规则二:按域名匹配生效
- domain:
- "*.example.com"
- test.com
enable_on_status:
- 429
status_code: 200
headers:
- Content-Type=application/json
body: "{\"errmsg\": \"rate limited\"}"
```
此例 `ingress` 中指定的 `default/foo``default/bar` 对应 default 命名空间下名为 foo 和 bar 的 Ingress当匹配到这两个 Ingress 时,将使用此段配置;
此例 `domain` 中指定的 `*.example.com``test.com` 用于匹配请求的域名,当发现域名匹配时,将使用此段配置;
配置的匹配生效顺序,将按照 `matchRules` 下规则的排列顺序,匹配第一个规则后生效对应配置,后续规则将被忽略。

View File

@@ -0,0 +1,24 @@
{
"targets": {
"debug": {
"outFile": "build/debug.wasm",
"textFile": "build/debug.wat",
"sourceMap": true,
"debug": true
},
"release": {
"outFile": "build/release.wasm",
"textFile": "build/release.wat",
"sourceMap": true,
"optimizeLevel": 3,
"shrinkLevel": 0,
"converge": false,
"noAssert": false,
"debug": true
}
},
"options": {
"bindings": "esm",
"use": "abort=abort_proc_exit"
}
}

View File

@@ -0,0 +1,96 @@
export * from "@higress/proxy-wasm-assemblyscript-sdk/assembly/proxy";
import { SetCtx, HttpContext, ProcessRequestHeadersBy, Logger, ParseConfigBy, ParseResult, ProcessResponseHeadersBy } from "@higress/wasm-assemblyscript/assembly";
import { FilterHeadersStatusValues, Headers, send_http_response, stream_context, HeaderPair } from "@higress/proxy-wasm-assemblyscript-sdk/assembly"
import { JSON } from "assemblyscript-json/assembly";
class CustomResponseConfig {
statusCode: u32;
headers: Headers;
body: ArrayBuffer;
enableOnStatus: Array<u32>;
contentType: string;
constructor() {
this.statusCode = 200;
this.headers = [];
this.body = new ArrayBuffer(0);
this.enableOnStatus = [];
this.contentType = "text/plain; charset=utf-8";
}
}
SetCtx<CustomResponseConfig>(
"custom-response",
[ParseConfigBy<CustomResponseConfig>(parseConfig),
ProcessRequestHeadersBy<CustomResponseConfig>(onHttpRequestHeaders),
ProcessResponseHeadersBy<CustomResponseConfig>(onHttpResponseHeaders),])
function parseConfig(json: JSON.Obj): ParseResult<CustomResponseConfig> {
let headersArray = json.getArr("headers");
let config = new CustomResponseConfig();
if (headersArray != null) {
for (let i = 0; i < headersArray.valueOf().length; i++) {
let header = headersArray._arr[i];
let jsonString = (<JSON.Str>header).toString()
let kv = jsonString.split("=")
if (kv.length == 2) {
let key = kv[0].trim();
let value = kv[1].trim();
if (key.toLowerCase() == "content-type") {
config.contentType = value;
} else if (key.toLowerCase() == "content-length") {
continue;
} else {
config.headers.push(new HeaderPair(String.UTF8.encode(key), String.UTF8.encode(value)));
}
} else {
Logger.Error("parse header failed");
return new ParseResult<CustomResponseConfig>(null, false);
}
}
}
let body = json.getString("body");
if (body != null) {
config.body = String.UTF8.encode(body.valueOf());
}
config.headers.push(new HeaderPair(String.UTF8.encode("content-type"), String.UTF8.encode(config.contentType)));
let statusCode = json.getInteger("statusCode");
if (statusCode != null) {
config.statusCode = statusCode.valueOf() as u32;
}
let enableOnStatus = json.getArr("enableOnStatus");
if (enableOnStatus != null) {
for (let i = 0; i < enableOnStatus.valueOf().length; i++) {
let status = enableOnStatus._arr[i];
if (status.isInteger) {
config.enableOnStatus.push((<JSON.Integer>status).valueOf() as u32);
}
}
}
return new ParseResult<CustomResponseConfig>(config, true);
}
function onHttpRequestHeaders(context: HttpContext, config: CustomResponseConfig): FilterHeadersStatusValues {
if (config.enableOnStatus.length != 0) {
return FilterHeadersStatusValues.Continue;
}
send_http_response(config.statusCode, "custom-response", config.body, config.headers);
return FilterHeadersStatusValues.StopIteration;
}
function onHttpResponseHeaders(context: HttpContext, config: CustomResponseConfig): FilterHeadersStatusValues {
let statusCodeStr = stream_context.headers.response.get(":status")
if (statusCodeStr == "") {
Logger.Error("get http response status code failed");
return FilterHeadersStatusValues.Continue;
}
let statusCode = parseInt(statusCodeStr);
for (let i = 0; i < config.enableOnStatus.length; i++) {
if (statusCode == config.enableOnStatus[i]) {
send_http_response(config.statusCode, "custom-response", config.body, config.headers);
}
}
return FilterHeadersStatusValues.Continue;
}

View File

@@ -0,0 +1,6 @@
{
"extends": "assemblyscript/std/assembly.json",
"include": [
"./**/*.ts"
]
}

View File

@@ -0,0 +1,68 @@
{
"name": "custom-response",
"version": "1.0.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "custom-response",
"version": "1.0.0",
"license": "ISC",
"devDependencies": {
"@higress/wasm-assemblyscript": "^0.0.4",
"assemblyscript": "^0.27.29",
"assemblyscript-json": "^1.1.0"
}
},
"node_modules/@higress/wasm-assemblyscript": {
"version": "0.0.4",
"resolved": "https://registry.npmjs.org/@higress/wasm-assemblyscript/-/wasm-assemblyscript-0.0.4.tgz",
"integrity": "sha512-F9m3fHBeM0OFWWHekTcmj3dVh7I4pbzf0oIioVdArD2oSUgpCZ8ur8E/9r7JR3WVwn2/l0A3LRSBOJTzQnHtMw==",
"dev": true
},
"node_modules/assemblyscript": {
"version": "0.27.29",
"resolved": "https://registry.npmmirror.com/assemblyscript/-/assemblyscript-0.27.29.tgz",
"integrity": "sha512-pH6udb7aE2F0t6cTh+0uCepmucykhMnAmm7k0kkAU3SY7LvpIngEBZWM6p5VCguu4EpmKGwEuZpZbEXzJ/frHQ==",
"dev": true,
"dependencies": {
"binaryen": "116.0.0-nightly.20240114",
"long": "^5.2.1"
},
"bin": {
"asc": "bin/asc.js",
"asinit": "bin/asinit.js"
},
"engines": {
"node": ">=16",
"npm": ">=7"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/assemblyscript"
}
},
"node_modules/assemblyscript-json": {
"version": "1.1.0",
"resolved": "https://registry.npmmirror.com/assemblyscript-json/-/assemblyscript-json-1.1.0.tgz",
"integrity": "sha512-UbE8ts8csTWQgd5TnSPN7MRV9NveuHv1bVnKmDLoo/tzjqxkmsZb3lu59Uk8H7SGoqdkDSEE049alx/nHnSdFw==",
"dev": true
},
"node_modules/binaryen": {
"version": "116.0.0-nightly.20240114",
"resolved": "https://registry.npmmirror.com/binaryen/-/binaryen-116.0.0-nightly.20240114.tgz",
"integrity": "sha512-0GZrojJnuhoe+hiwji7QFaL3tBlJoA+KFUN7ouYSDGZLSo9CKM8swQX8n/UcbR0d1VuZKU+nhogNzv423JEu5A==",
"dev": true,
"bin": {
"wasm-opt": "bin/wasm-opt",
"wasm2js": "bin/wasm2js"
}
},
"node_modules/long": {
"version": "5.2.3",
"resolved": "https://registry.npmmirror.com/long/-/long-5.2.3.tgz",
"integrity": "sha512-lcHwpNoggQTObv5apGNCTdJrO69eHOZMi4BNC+rTLER8iHAqGrUVeLh/irVIM7zTw2bOXA8T6uNPeujwOLg/2Q==",
"dev": true
}
}
}

View File

@@ -0,0 +1,27 @@
{
"name": "custom-response",
"version": "1.0.0",
"main": "index.js",
"scripts": {
"test": "node tests",
"asbuild:debug": "asc assembly/index.ts --target debug",
"asbuild:release": "asc assembly/index.ts --target release",
"asbuild": "npm run asbuild:debug && npm run asbuild:release",
"start": "npx serve ."
},
"author": "",
"license": "ISC",
"description": "",
"devDependencies": {
"assemblyscript": "^0.27.29",
"assemblyscript-json": "^1.1.0",
"@higress/wasm-assemblyscript": "^0.0.4"
},
"type": "module",
"exports": {
".": {
"import": "./build/release.js",
"types": "./build/release.d.ts"
}
}
}

View File

@@ -0,0 +1,24 @@
{
"targets": {
"debug": {
"outFile": "build/debug.wasm",
"textFile": "build/debug.wat",
"sourceMap": true,
"debug": true
},
"release": {
"outFile": "build/release.wasm",
"textFile": "build/release.wat",
"sourceMap": true,
"optimizeLevel": 3,
"shrinkLevel": 0,
"converge": false,
"noAssert": false,
"debug": true
}
},
"options": {
"bindings": "esm",
"use": "abort=abort_proc_exit"
}
}

View File

@@ -0,0 +1,42 @@
export * from "@higress/proxy-wasm-assemblyscript-sdk/assembly/proxy";
import { SetCtx, HttpContext, ProcessRequestHeadersBy, Logger, ParseResult, ParseConfigBy, RegisteTickFunc, ProcessResponseHeadersBy } from "@higress/wasm-assemblyscript/assembly";
import { FilterHeadersStatusValues, send_http_response, stream_context } from "@higress/proxy-wasm-assemblyscript-sdk/assembly"
import { JSON } from "assemblyscript-json/assembly";
class HelloWorldConfig {
}
SetCtx<HelloWorldConfig>("hello-world",
[ParseConfigBy<HelloWorldConfig>(parseConfig),
ProcessRequestHeadersBy<HelloWorldConfig>(onHttpRequestHeaders),
ProcessResponseHeadersBy<HelloWorldConfig>(onHttpResponseHeaders)
])
function parseConfig(json: JSON.Obj): ParseResult<HelloWorldConfig> {
RegisteTickFunc(2000, () => {
Logger.Debug("tick 2s");
})
RegisteTickFunc(5000, () => {
Logger.Debug("tick 5s");
})
return new ParseResult<HelloWorldConfig>(new HelloWorldConfig(), true);
}
class TestContext{
value: string
constructor(value: string){
this.value = value
}
}
function onHttpRequestHeaders(context: HttpContext, config: HelloWorldConfig): FilterHeadersStatusValues {
stream_context.headers.request.add("hello", "world");
Logger.Debug("[hello-world] logger test");
context.SetContext("test-set-context", changetype<usize>(new TestContext("value")))
send_http_response(200, "hello-world", String.UTF8.encode("[wasm-assemblyscript]hello world"), []);
return FilterHeadersStatusValues.Continue;
}
function onHttpResponseHeaders(context: HttpContext, config: HelloWorldConfig): FilterHeadersStatusValues {
const str = changetype<TestContext>(context.GetContext("test-set-context")).value;
Logger.Debug("[hello-world] test-set-context: " + str);
return FilterHeadersStatusValues.Continue;
}

View File

@@ -0,0 +1,6 @@
{
"extends": "assemblyscript/std/assembly.json",
"include": [
"./**/*.ts"
]
}

View File

@@ -0,0 +1,68 @@
{
"name": "hello-world",
"version": "1.0.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "hello-world",
"version": "1.0.0",
"license": "ISC",
"devDependencies": {
"@higress/wasm-assemblyscript": "^0.0.4",
"assemblyscript": "^0.27.29",
"assemblyscript-json": "^1.1.0"
}
},
"node_modules/@higress/wasm-assemblyscript": {
"version": "0.0.4",
"resolved": "https://registry.npmjs.org/@higress/wasm-assemblyscript/-/wasm-assemblyscript-0.0.4.tgz",
"integrity": "sha512-F9m3fHBeM0OFWWHekTcmj3dVh7I4pbzf0oIioVdArD2oSUgpCZ8ur8E/9r7JR3WVwn2/l0A3LRSBOJTzQnHtMw==",
"dev": true
},
"node_modules/assemblyscript": {
"version": "0.27.29",
"resolved": "https://registry.npmmirror.com/assemblyscript/-/assemblyscript-0.27.29.tgz",
"integrity": "sha512-pH6udb7aE2F0t6cTh+0uCepmucykhMnAmm7k0kkAU3SY7LvpIngEBZWM6p5VCguu4EpmKGwEuZpZbEXzJ/frHQ==",
"dev": true,
"dependencies": {
"binaryen": "116.0.0-nightly.20240114",
"long": "^5.2.1"
},
"bin": {
"asc": "bin/asc.js",
"asinit": "bin/asinit.js"
},
"engines": {
"node": ">=16",
"npm": ">=7"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/assemblyscript"
}
},
"node_modules/assemblyscript-json": {
"version": "1.1.0",
"resolved": "https://registry.npmmirror.com/assemblyscript-json/-/assemblyscript-json-1.1.0.tgz",
"integrity": "sha512-UbE8ts8csTWQgd5TnSPN7MRV9NveuHv1bVnKmDLoo/tzjqxkmsZb3lu59Uk8H7SGoqdkDSEE049alx/nHnSdFw==",
"dev": true
},
"node_modules/binaryen": {
"version": "116.0.0-nightly.20240114",
"resolved": "https://registry.npmmirror.com/binaryen/-/binaryen-116.0.0-nightly.20240114.tgz",
"integrity": "sha512-0GZrojJnuhoe+hiwji7QFaL3tBlJoA+KFUN7ouYSDGZLSo9CKM8swQX8n/UcbR0d1VuZKU+nhogNzv423JEu5A==",
"dev": true,
"bin": {
"wasm-opt": "bin/wasm-opt",
"wasm2js": "bin/wasm2js"
}
},
"node_modules/long": {
"version": "5.2.3",
"resolved": "https://registry.npmmirror.com/long/-/long-5.2.3.tgz",
"integrity": "sha512-lcHwpNoggQTObv5apGNCTdJrO69eHOZMi4BNC+rTLER8iHAqGrUVeLh/irVIM7zTw2bOXA8T6uNPeujwOLg/2Q==",
"dev": true
}
}
}

View File

@@ -0,0 +1,27 @@
{
"name": "hello-world",
"version": "1.0.0",
"main": "index.js",
"scripts": {
"test": "node tests",
"asbuild:debug": "asc assembly/index.ts --target debug",
"asbuild:release": "asc assembly/index.ts --target release",
"asbuild": "npm run asbuild:debug && npm run asbuild:release",
"start": "npx serve ."
},
"author": "",
"license": "ISC",
"description": "",
"devDependencies": {
"assemblyscript": "^0.27.29",
"assemblyscript-json": "^1.1.0",
"@higress/wasm-assemblyscript": "^0.0.4"
},
"type": "module",
"exports": {
".": {
"import": "./build/release.js",
"types": "./build/release.d.ts"
}
}
}

View File

@@ -0,0 +1,75 @@
{
"name": "@higress/wasm-assemblyscript",
"version": "0.0.4",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "@higress/wasm-assemblyscript",
"version": "0.0.4",
"license": "Apache-2.0",
"devDependencies": {
"@higress/proxy-wasm-assemblyscript-sdk": "^0.0.2",
"as-uuid": "^0.0.4",
"assemblyscript": "^0.27.29",
"assemblyscript-json": "^1.1.0"
}
},
"node_modules/@higress/proxy-wasm-assemblyscript-sdk": {
"version": "0.0.2",
"resolved": "https://registry.npmmirror.com/@higress/proxy-wasm-assemblyscript-sdk/-/proxy-wasm-assemblyscript-sdk-0.0.2.tgz",
"integrity": "sha512-0J1tFJMTE6o37JpGJBLq0wc5kBC/fpbISrP+KFb4bAEeshu6daXzD2P3bAfJXmW+oZdY0WGptTGXWx8pf9Fk+g==",
"dev": true
},
"node_modules/as-uuid": {
"version": "0.0.4",
"resolved": "https://registry.npmmirror.com/as-uuid/-/as-uuid-0.0.4.tgz",
"integrity": "sha512-ZHNv0ETSzg5ZD0IWWJVyip/73LWtrWeMmvRi+16xbkpU/nZ0O8EegvgS7bgZ5xRqrUbc2NqZqHOWMOtPqbLrhg==",
"dev": true
},
"node_modules/assemblyscript": {
"version": "0.27.29",
"resolved": "https://registry.npmmirror.com/assemblyscript/-/assemblyscript-0.27.29.tgz",
"integrity": "sha512-pH6udb7aE2F0t6cTh+0uCepmucykhMnAmm7k0kkAU3SY7LvpIngEBZWM6p5VCguu4EpmKGwEuZpZbEXzJ/frHQ==",
"dev": true,
"dependencies": {
"binaryen": "116.0.0-nightly.20240114",
"long": "^5.2.1"
},
"bin": {
"asc": "bin/asc.js",
"asinit": "bin/asinit.js"
},
"engines": {
"node": ">=16",
"npm": ">=7"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/assemblyscript"
}
},
"node_modules/assemblyscript-json": {
"version": "1.1.0",
"resolved": "https://registry.npmmirror.com/assemblyscript-json/-/assemblyscript-json-1.1.0.tgz",
"integrity": "sha512-UbE8ts8csTWQgd5TnSPN7MRV9NveuHv1bVnKmDLoo/tzjqxkmsZb3lu59Uk8H7SGoqdkDSEE049alx/nHnSdFw==",
"dev": true
},
"node_modules/binaryen": {
"version": "116.0.0-nightly.20240114",
"resolved": "https://registry.npmmirror.com/binaryen/-/binaryen-116.0.0-nightly.20240114.tgz",
"integrity": "sha512-0GZrojJnuhoe+hiwji7QFaL3tBlJoA+KFUN7ouYSDGZLSo9CKM8swQX8n/UcbR0d1VuZKU+nhogNzv423JEu5A==",
"dev": true,
"bin": {
"wasm-opt": "bin/wasm-opt",
"wasm2js": "bin/wasm2js"
}
},
"node_modules/long": {
"version": "5.2.3",
"resolved": "https://registry.npmmirror.com/long/-/long-5.2.3.tgz",
"integrity": "sha512-lcHwpNoggQTObv5apGNCTdJrO69eHOZMi4BNC+rTLER8iHAqGrUVeLh/irVIM7zTw2bOXA8T6uNPeujwOLg/2Q==",
"dev": true
}
}
}

View File

@@ -0,0 +1,37 @@
{
"name": "@higress/wasm-assemblyscript",
"version": "0.0.4",
"main": "assembly/index.ts",
"scripts": {
"test": "node tests",
"asbuild:debug": "asc assembly/index.ts --target debug",
"asbuild:release": "asc assembly/index.ts --target release",
"asbuild": "npm run asbuild:debug && npm run asbuild:release",
"start": "npx serve ."
},
"author": "jingze.dai",
"license": "Apache-2.0",
"description": "",
"devDependencies": {
"assemblyscript": "^0.27.29",
"as-uuid": "^0.0.4",
"assemblyscript-json": "^1.1.0",
"@higress/proxy-wasm-assemblyscript-sdk": "^0.0.2"
},
"type": "module",
"exports": {
".": {
"import": "./build/release.js",
"types": "./build/release.d.ts"
}
},
"files": [
"/assembly",
"package-lock.json",
"index.js"
],
"repository": {
"type": "git",
"url": "git+https://github.com/Jing-ze/wasm-assemblyscript.git"
}
}

View File

@@ -0,0 +1,350 @@
---
title: AI Agent
keywords: [ AI网关, AI Agent ]
description: AI Agent插件配置参考
---
## 功能说明
一个可定制化的 API AI Agent支持配置 http method 类型为 GET 与 POST 的 API目前只支持非流式模式。
agent流程图如下
![ai-agent](https://github.com/user-attachments/assets/b0761a0c-1afa-496c-a98e-bb9f38b340f8)
## 配置字段
### 基本配置
| 名称 | 数据类型 | 填写要求 | 默认值 | 描述 |
|------------------|-----------|---------|--------|----------------------------|
| `llm` | object | 必填 | - | 配置 AI 服务提供商的信息 |
| `apis` | object | 必填 | - | 配置外部 API 服务提供商的信息 |
| `promptTemplate` | object | 非必填 | - | 配置 Agent ReAct 模板的信息 |
`llm`的配置字段说明如下:
| 名称 | 数据类型 | 填写要求 | 默认值 | 描述 |
|--------------------|-----------|---------|--------|-----------------------------------|
| `apiKey` | string | 必填 | - | 用于在访问大模型服务时进行认证的令牌。|
| `serviceName` | string | 必填 | - | 大模型服务名 |
| `servicePort` | int | 必填 | - | 大模型服务端口 |
| `domain` | string | 必填 | - | 访问大模型服务时域名 |
| `path` | string | 必填 | - | 访问大模型服务时路径 |
| `model` | string | 必填 | - | 访问大模型服务时模型名 |
| `maxIterations` | int | 必填 | 15 | 结束执行循环前的最大步数 |
| `maxExecutionTime` | int | 必填 | 50000 | 每一次请求大模型的超时时间,单位毫秒 |
| `maxTokens` | int | 必填 | 1000 | 每一次请求大模型的输出token限制 |
`apis`的配置字段说明如下:
| 名称 | 数据类型 | 填写要求 | 默认值 | 描述 |
|-----------------|-----------|---------|--------|-----------------------------------|
| `apiProvider` | object | 必填 | - | 外部 API 服务信息 |
| `api` | string | 必填 | - | 工具的 OpenAPI 文档 |
`apiProvider`的配置字段说明如下:
| 名称 | 数据类型 | 填写要求 | 默认值 | 描述 |
|-----------------|-----------|---------|--------|------------------------------------------|
| `apiKey` | object | 非必填 | - | 用于在访问外部 API 服务时进行认证的令牌。 |
| `serviceName` | string | 必填 | - | 访问外部 API 服务名 |
| `servicePort` | int | 必填 | - | 访问外部 API 服务端口 |
| `domain` | string | 必填 | - | 访访问外部 API 时域名 |
`apiKey`的配置字段说明如下:
| 名称 | 数据类型 | 填写要求 | 默认值 | 描述 |
|-------------------|---------|------------|--------|-------------------------------------------------------------------------------|
| `in` | string | 非必填 | header | 在访问外部 API 服务时进行认证的令牌是放在 header 中还是放在 query 中,默认是 header。
| `name` | string | 非必填 | - | 用于在访问外部 API 服务时进行认证的令牌的名称。 |
| `value` | string | 非必填 | - | 用于在访问外部 API 服务时进行认证的令牌的值。 |
`promptTemplate`的配置字段说明如下:
| 名称 | 数据类型 | 填写要求 | 默认值 | 描述 |
|-----------------|-----------|-----------|--------|--------------------------------------------|
| `language` | string | 非必填 | EN | Agent ReAct 模板的语言类型,包括 CH 和 EN 两种|
| `chTemplate` | object | 非必填 | - | Agent ReAct 中文模板 |
| `enTemplate` | object | 非必填 | - | Agent ReAct 英文模板 |
`chTemplate``enTemplate`的配置字段说明如下:
| 名称 | 数据类型 | 填写要求 | 默认值 | 描述 |
|-----------------|-----------|-----------|--------|---------------------------------------------|
| `question` | string | 非必填 | - | Agent ReAct 模板的 question 部分 |
| `thought1` | string | 非必填 | - | Agent ReAct 模板的 thought1 部分 |
| `actionInput` | string | 非必填 | - | Agent ReAct 模板的 actionInput 部分 |
| `observation` | string | 非必填 | - | Agent ReAct 模板的 observation 部分 |
| `thought2` | string | 非必填 | - | Agent ReAct 模板的 thought2 部分 |
| `finalAnswer` | string | 非必填 | - | Agent ReAct 模板的 finalAnswer 部分 |
| `begin` | string | 非必填 | - | Agent ReAct 模板的 begin 部分 |
## 用法示例
**配置信息**
```yaml
llm:
apiKey: xxxxxxxxxxxxxxxxxx
domain: dashscope.aliyuncs.com
serviceName: dashscope.dns
servicePort: 443
path: /compatible-mode/v1/chat/completions
model: qwen-max-0403
maxIterations: 2
promptTemplate:
language: CH
apis:
- apiProvider:
domain: restapi.amap.com
serviceName: geo.dns
servicePort: 80
apiKey:
in: query
name: key
value: xxxxxxxxxxxxxxx
api: |
openapi: 3.1.0
info:
title: 高德地图
description: 获取 POI 的相关信息
version: v1.0.0
servers:
- url: https://restapi.amap.com
paths:
/v5/place/text:
get:
description: 根据POI名称获得POI的经纬度坐标
operationId: get_location_coordinate
parameters:
- name: keywords
in: query
description: POI名称必须是中文
required: true
schema:
type: string
- name: region
in: query
description: POI所在的区域名必须是中文
required: true
schema:
type: string
deprecated: false
/v5/place/around:
get:
description: 搜索给定坐标附近的POI
operationId: search_nearby_pois
parameters:
- name: keywords
in: query
description: 目标POI的关键字
required: true
schema:
type: string
- name: location
in: query
description: 中心点的经度和纬度,用逗号隔开
required: true
schema:
type: string
deprecated: false
components:
schemas: {}
- apiProvider:
domain: api.seniverse.com
serviceName: seniverse.dns
servicePort: 80
apiKey:
in: query
name: key
value: xxxxxxxxxxxxxxx
api: |
openapi: 3.1.0
info:
title: 心知天气
description: 获取 天气预办相关信息
version: v1.0.0
servers:
- url: https://api.seniverse.com
paths:
/v3/weather/now.json:
get:
description: 获取指定城市的天气实况
operationId: get_weather_now
parameters:
- name: location
in: query
description: 所查询的城市
required: true
schema:
type: string
- name: language
in: query
description: 返回天气查询结果所使用的语言
required: true
schema:
type: string
default: zh-Hans
enum:
- zh-Hans
- en
- ja
- name: unit
in: query
description: 表示温度的的单位,有摄氏度和华氏度两种
required: true
schema:
type: string
default: c
enum:
- c
- f
deprecated: false
components:
schemas: {}
- apiProvider:
apiKey:
in: "header"
name: "DeepL-Auth-Key"
value: "73xxxxxxxxxxxxxxx:fx"
domain: "api-free.deepl.com"
serviceName: "deepl.dns"
servicePort: 443
api: |
openapi: 3.1.0
info:
title: DeepL API Documentation
description: The DeepL API provides programmatic access to DeepLs machine translation technology.
version: v1.0.0
servers:
- url: https://api-free.deepl.com/v2
paths:
/translate:
post:
summary: Request Translation
operationId: translateText
requestBody:
required: true
content:
application/json:
schema:
type: object
required:
- text
- target_lang
properties:
text:
description: |
Text to be translated. Only UTF-8-encoded plain text is supported. The parameter may be specified
up to 50 times in a single request. Translations are returned in the same order as they are requested.
type: array
maxItems: 50
items:
type: string
example: Hello, World!
target_lang:
description: The language into which the text should be translated.
type: string
enum:
- BG
- CS
- DA
- DE
- EL
- EN-GB
- EN-US
- ES
- ET
- FI
- FR
- HU
- ID
- IT
- JA
- KO
- LT
- LV
- NB
- NL
- PL
- PT-BR
- PT-PT
- RO
- RU
- SK
- SL
- SV
- TR
- UK
- ZH
- ZH-HANS
example: DE
components:
schemas: {}
```
本示例配置了三个服务演示了get与post两种类型的工具。其中get类型的工具包括高德地图与心知天气post类型的工具是deepl翻译。三个服务都需要现在Higress的服务中以DNS域名的方式配置好并确保健康。
高德地图提供了两个工具分别是获取指定地点的坐标以及搜索坐标附近的感兴趣的地点。文档https://lbs.amap.com/api/webservice/guide/api-advanced/newpoisearch
心知天气提供了一个工具用于获取指定城市的实时天气情况支持中文英文日语返回以及摄氏度和华氏度的表示。文档https://seniverse.yuque.com/hyper_data/api_v3/nyiu3t
deepl提供了一个工具用于翻译给定的句子支持多语言。。文档https://developers.deepl.com/docs/v/zh/api-reference/translate?fallback=true
以下为测试用例为了效果的稳定性建议保持大模型版本的稳定本例子中使用的qwen-max-0403
**请求示例**
```shell
curl 'http://<这里换成网关公网IP>/api/openai/v1/chat/completions' \
-H 'Accept: application/json, text/event-stream' \
-H 'Content-Type: application/json' \
--data-raw '{"model":"qwen","frequency_penalty":0,"max_tokens":800,"stream":false,"messages":[{"role":"user","content":"我想在济南市鑫盛大厦附近喝咖啡,给我推荐几个"}],"presence_penalty":0,"temperature":0,"top_p":0}'
```
**响应示例**
```json
{"id":"139487e7-96a0-9b13-91b4-290fb79ac992","choices":[{"index":0,"message":{"role":"assistant","content":" 在济南市鑫盛大厦附近,您可以选择以下咖啡店:\n1. luckin coffee 瑞幸咖啡(鑫盛大厦店)位于新泺大街1299号鑫盛大厦2号楼大堂\n2. 三庆齐盛广场挪瓦咖啡(三庆·齐盛广场店)位于新泺大街与颖秀路交叉口西南60米\n3. luckin coffee 瑞幸咖啡(三庆·齐盛广场店)位于颖秀路1267号\n4. 库迪咖啡(齐鲁软件园店)位于新泺大街三庆齐盛广场4号楼底商\n5. 库迪咖啡(美莲广场店)位于高新区新泺大街1166号美莲广场L117号以及其他一些选项。希望这些建议对您有所帮助"},"finish_reason":"stop"}],"created":1723172296,"model":"qwen-max-0403","object":"chat.completion","usage":{"prompt_tokens":886,"completion_tokens":50,"total_tokens":936}}
```
**请求示例**
```shell
curl 'http://<这里换成网关公网IP>/api/openai/v1/chat/completions' \
-H 'Accept: application/json, text/event-stream' \
-H 'Content-Type: application/json' \
--data-raw '{"model":"qwen","frequency_penalty":0,"max_tokens":800,"stream":false,"messages":[{"role":"user","content":"济南市现在的天气情况如何?"}],"presence_penalty":0,"temperature":0,"top_p":0}'
```
**响应示例**
```json
{"id":"ebd6ea91-8e38-9e14-9a5b-90178d2edea4","choices":[{"index":0,"message":{"role":"assistant","content":" 济南市现在的天气状况为阴天温度为31℃。此信息最后更新于2024年8月9日15时12分北京时间。"},"finish_reason":"stop"}],"created":1723187991,"model":"qwen-max-0403","object":"chat.completion","usage":{"prompt_tokens":890,"completion_tokens":56,"total_tokens":946}}
```
**请求示例**
```shell
curl 'http://<这里换成网关公网IP>/api/openai/v1/chat/completions' \
-H 'Accept: application/json, text/event-stream' \
-H 'Content-Type: application/json' \
--data-raw '{"model":"qwen","frequency_penalty":0,"max_tokens":800,"stream":false,"messages":[{"role":"user","content":"济南市现在的天气情况如何?用华氏度表示,用日语回答"}],"presence_penalty":0,"temperature":0,"top_p":0}'
```
**响应示例**
```json
{"id":"ebd6ea91-8e38-9e14-9a5b-90178d2edea4","choices":[{"index":0,"message":{"role":"assistant","content":" 济南市の現在の天気は雨曇りで、気温は88°Fです。この情報は2024年8月9日15時12分東京時間に更新されました。"},"finish_reason":"stop"}],"created":1723187991,"model":"qwen-max-0403","object":"chat.completion","usage":{"prompt_tokens":890,"completion_tokens":56,"total_tokens":946}}
```
**请求示例**
```shell
curl 'http://<这里换成网关公网IP>/api/openai/v1/chat/completions' \
-H 'Accept: application/json, text/event-stream' \
-H 'Content-Type: application/json' \
--data-raw '{"model":"qwen","frequency_penalty":0,"max_tokens":800,"stream":false,"messages":[{"role":"user","content":"帮我用德语翻译以下句子:九头蛇万岁!"}],"presence_penalty":0,"temperature":0,"top_p":0}'
```
**响应示例**
```json
{"id":"65dcf12c-61ff-9e68-bffa-44fc9e6070d5","choices":[{"index":0,"message":{"role":"assistant","content":" “九头蛇万岁!”的德语翻译为“Hoch lebe Hydra!”。"},"finish_reason":"stop"}],"created":1724043865,"model":"qwen-max-0403","object":"chat.completion","usage":{"prompt_tokens":908,"completion_tokens":52,"total_tokens":960}}
```

View File

@@ -0,0 +1,424 @@
package main
import (
"encoding/json"
"errors"
"github.com/alibaba/higress/plugins/wasm-go/pkg/wrapper"
"github.com/tidwall/gjson"
"gopkg.in/yaml.v2"
)
type Message struct {
Role string `json:"role"`
Content string `json:"content"`
}
type Request struct {
Model string `json:"model"`
Messages []Message `json:"messages"`
FrequencyPenalty float64 `json:"frequency_penalty"`
PresencePenalty float64 `json:"presence_penalty"`
Stream bool `json:"stream"`
Temperature float64 `json:"temperature"`
Topp int32 `json:"top_p"`
}
type Choice struct {
Index int `json:"index"`
Message Message `json:"message"`
FinishReason string `json:"finish_reason"`
}
type Usage struct {
PromptTokens int `json:"prompt_tokens"`
CompletionTokens int `json:"completion_tokens"`
TotalTokens int `json:"total_tokens"`
}
type Response struct {
ID string `json:"id"`
Choices []Choice `json:"choices"`
Created int64 `json:"created"`
Model string `json:"model"`
Object string `json:"object"`
Usage Usage `json:"usage"`
}
// 用于存放拆解出来的工具相关信息
type Tool_Param struct {
ToolName string `yaml:"toolName"`
Path string `yaml:"path"`
Method string `yaml:"method"`
ParamName []string `yaml:"paramName"`
Parameter string `yaml:"parameter"`
Description string `yaml:"description"`
}
// 用于存放拆解出来的api相关信息
type APIParam struct {
APIKey APIKey `yaml:"apiKey"`
URL string `yaml:"url"`
Tool_Param []Tool_Param `yaml:"tool_Param"`
}
type Info struct {
Title string `yaml:"title"`
Description string `yaml:"description"`
Version string `yaml:"version"`
}
type Server struct {
URL string `yaml:"url"`
}
// 给OpenAPI的get方法用的
type Parameter struct {
Name string `yaml:"name"`
In string `yaml:"in"`
Description string `yaml:"description"`
Required bool `yaml:"required"`
Schema struct {
Type string `yaml:"type"`
Default string `yaml:"default"`
Enum []string `yaml:"enum"`
} `yaml:"schema"`
}
type Items struct {
Type string `yaml:"type"`
Example string `yaml:"example"`
}
type Property struct {
Description string `yaml:"description"`
Type string `yaml:"type"`
Enum []string `yaml:"enum,omitempty"`
Items *Items `yaml:"items,omitempty"`
MaxItems int `yaml:"maxItems,omitempty"`
Example string `yaml:"example,omitempty"`
}
type Schema struct {
Type string `yaml:"type"`
Required []string `yaml:"required"`
Properties map[string]Property `yaml:"properties"`
}
type MediaType struct {
Schema Schema `yaml:"schema"`
}
// 给OpenAPI的post方法用的
type RequestBody struct {
Required bool `yaml:"required"`
Content map[string]MediaType `yaml:"content"`
}
type PathItem struct {
Description string `yaml:"description"`
Summary string `yaml:"summary"`
OperationID string `yaml:"operationId"`
RequestBody RequestBody `yaml:"requestBody"`
Parameters []Parameter `yaml:"parameters"`
Deprecated bool `yaml:"deprecated"`
}
type Paths map[string]map[string]PathItem
type Components struct {
Schemas map[string]interface{} `yaml:"schemas"`
}
type API struct {
OpenAPI string `yaml:"openapi"`
Info Info `yaml:"info"`
Servers []Server `yaml:"servers"`
Paths Paths `yaml:"paths"`
Components Components `yaml:"components"`
}
type APIKey struct {
In string `yaml:"in" json:"in"`
Name string `yaml:"name" json:"name"`
Value string `yaml:"value" json:"value"`
}
type APIProvider struct {
// @Title zh-CN 服务名称
// @Description zh-CN 带服务类型的完整 FQDN 名称,例如 my-redis.dns、redis.my-ns.svc.cluster.local
ServiceName string `required:"true" yaml:"serviceName" json:"serviceName"`
// @Title zh-CN 服务端口
// @Description zh-CN 服务端口
ServicePort int64 `required:"true" yaml:"servicePort" json:"servicePort"`
// @Title zh-CN 服务域名
// @Description zh-CN 服务域名,例如 restapi.amap.com
Domin string `required:"true" yaml:"domain" json:"domain"`
// @Title zh-CN 通义千问大模型服务的key
// @Description zh-CN 通义千问大模型服务的key
APIKey APIKey `required:"true" yaml:"apiKey" json:"apiKey"`
}
type APIs struct {
APIProvider APIProvider `required:"true" yaml:"apiProvider" json:"apiProvider"`
API string `required:"true" yaml:"api" json:"api"`
}
type Template struct {
Question string `yaml:"question" json:"question"`
Thought1 string `yaml:"thought1" json:"thought1"`
ActionInput string `yaml:"actionInput" json:"actionInput"`
Observation string `yaml:"observation" json:"observation"`
Thought2 string `yaml:"thought2" json:"thought2"`
FinalAnswer string `yaml:"finalAnswer" json:"finalAnswer"`
Begin string `yaml:"begin" json:"begin"`
}
type PromptTemplate struct {
Language string `required:"true" yaml:"language" json:"language"`
CHTemplate Template `yaml:"chTemplate" json:"chTemplate"`
ENTemplate Template `yaml:"enTemplate" json:"enTemplate"`
}
type LLMInfo struct {
// @Title zh-CN 大模型服务名称
// @Description zh-CN 带服务类型的完整 FQDN 名称
ServiceName string `required:"true" yaml:"serviceName" json:"serviceName"`
// @Title zh-CN 大模型服务端口
// @Description zh-CN 服务端口
ServicePort int64 `required:"true" yaml:"servicePort" json:"servicePort"`
// @Title zh-CN 大模型服务域名
// @Description zh-CN 大模型服务域名,例如 dashscope.aliyuncs.com
Domin string `required:"true" yaml:"domin" json:"domin"`
// @Title zh-CN 大模型服务的key
// @Description zh-CN 大模型服务的key
APIKey string `required:"true" yaml:"apiKey" json:"apiKey"`
// @Title zh-CN 大模型服务的请求路径
// @Description zh-CN 大模型服务的请求路径,如"/compatible-mode/v1/chat/completions"
Path string `required:"true" yaml:"path" json:"path"`
// @Title zh-CN 大模型服务的模型名称
// @Description zh-CN 大模型服务的模型名称,如"qwen-max-0403"
Model string `required:"true" yaml:"model" json:"model"`
// @Title zh-CN 结束执行循环前的最大步数
// @Description zh-CN 结束执行循环前的最大步数比如2设置为0可能会无限循环直到超时退出默认15
MaxIterations int64 `yaml:"maxIterations" json:"maxIterations"`
// @Title zh-CN 每一次请求大模型的超时时间
// @Description zh-CN 每一次请求大模型的超时时间单位毫秒默认50000
MaxExecutionTime int64 `yaml:"maxExecutionTime" json:"maxExecutionTime"`
// @Title zh-CN
// @Description zh-CN 每一次请求大模型的输出token限制默认1000
MaxTokens int64 `yaml:"maxToken" json:"maxTokens"`
}
type PluginConfig struct {
// @Title zh-CN 返回 HTTP 响应的模版
// @Description zh-CN 用 %s 标记需要被 cache value 替换的部分
ReturnResponseTemplate string `required:"true" yaml:"returnResponseTemplate" json:"returnResponseTemplate"`
// @Title zh-CN 工具服务商以及工具信息
// @Description zh-CN 用于存储工具服务商以及工具信息
APIs []APIs `required:"true" yaml:"apis" json:"apis"`
APIClient []wrapper.HttpClient `yaml:"-" json:"-"`
// @Title zh-CN llm信息
// @Description zh-CN 用于存储llm使用信息
LLMInfo LLMInfo `required:"true" yaml:"llm" json:"llm"`
LLMClient wrapper.HttpClient `yaml:"-" json:"-"`
APIParam []APIParam `yaml:"-" json:"-"`
PromptTemplate PromptTemplate `yaml:"promptTemplate" json:"promptTemplate"`
}
func initResponsePromptTpl(gjson gjson.Result, c *PluginConfig) {
//设置回复模板
c.ReturnResponseTemplate = gjson.Get("returnResponseTemplate").String()
if c.ReturnResponseTemplate == "" {
c.ReturnResponseTemplate = `{"id":"error","choices":[{"index":0,"message":{"role":"assistant","content":"%s"},"finish_reason":"stop"}],"model":"gpt-4o","object":"chat.completion","usage":{"prompt_tokens":0,"completion_tokens":0,"total_tokens":0}}`
}
}
func initAPIs(gjson gjson.Result, c *PluginConfig) error {
//从插件配置中获取apis信息
apis := gjson.Get("apis")
if !apis.Exists() {
return errors.New("apis is required")
}
if len(apis.Array()) == 0 {
return errors.New("apis cannot be empty")
}
for _, item := range apis.Array() {
serviceName := item.Get("apiProvider.serviceName")
if !serviceName.Exists() || serviceName.String() == "" {
return errors.New("apiProvider serviceName is required")
}
servicePort := item.Get("apiProvider.servicePort")
if !servicePort.Exists() || servicePort.Int() == 0 {
return errors.New("apiProvider servicePort is required")
}
domain := item.Get("apiProvider.domain")
if !domain.Exists() || domain.String() == "" {
return errors.New("apiProvider domain is required")
}
apiKeyIn := item.Get("apiProvider.apiKey.in").String()
if apiKeyIn != "query" {
apiKeyIn = "header"
}
apiKeyName := item.Get("apiProvider.apiKey.name")
apiKeyValue := item.Get("apiProvider.apiKey.value")
//根据多个toolsClientInfo的信息分别初始化toolsClient
apiClient := wrapper.NewClusterClient(wrapper.FQDNCluster{
FQDN: serviceName.String(),
Port: servicePort.Int(),
Host: domain.String(),
})
c.APIClient = append(c.APIClient, apiClient)
api := item.Get("api")
if !api.Exists() || api.String() == "" {
return errors.New("api is required")
}
var apiStruct API
err := yaml.Unmarshal([]byte(api.String()), &apiStruct)
if err != nil {
return err
}
var allTool_param []Tool_Param
//拆除服务下面的每个api的path
for path, pathmap := range apiStruct.Paths {
//拆解出每个api对应的参数
for method, submap := range pathmap {
//把参数列表存起来
var param Tool_Param
param.Path = path
param.ToolName = submap.OperationID
if method == "get" {
param.Method = "GET"
paramName := make([]string, 0)
for _, parammeter := range submap.Parameters {
paramName = append(paramName, parammeter.Name)
}
param.ParamName = paramName
out, _ := json.Marshal(submap.Parameters)
param.Parameter = string(out)
param.Description = submap.Description
} else if method == "post" {
param.Method = "POST"
schema := submap.RequestBody.Content["application/json"].Schema
param.ParamName = schema.Required
param.Description = submap.Summary
out, _ := json.Marshal(schema.Properties)
param.Parameter = string(out)
}
allTool_param = append(allTool_param, param)
}
}
apiParam := APIParam{
APIKey: APIKey{In: apiKeyIn, Name: apiKeyName.String(), Value: apiKeyValue.String()},
URL: apiStruct.Servers[0].URL,
Tool_Param: allTool_param,
}
c.APIParam = append(c.APIParam, apiParam)
}
return nil
}
func initReActPromptTpl(gjson gjson.Result, c *PluginConfig) {
c.PromptTemplate.Language = gjson.Get("promptTemplate.language").String()
if c.PromptTemplate.Language != "EN" && c.PromptTemplate.Language != "CH" {
c.PromptTemplate.Language = "EN"
}
if c.PromptTemplate.Language == "EN" {
c.PromptTemplate.ENTemplate.Question = gjson.Get("promptTemplate.enTemplate.question").String()
if c.PromptTemplate.ENTemplate.Question == "" {
c.PromptTemplate.ENTemplate.Question = "the input question you must answer"
}
c.PromptTemplate.ENTemplate.Thought1 = gjson.Get("promptTemplate.enTemplate.thought1").String()
if c.PromptTemplate.ENTemplate.Thought1 == "" {
c.PromptTemplate.ENTemplate.Thought1 = "you should always think about what to do"
}
c.PromptTemplate.ENTemplate.ActionInput = gjson.Get("promptTemplate.enTemplate.actionInput").String()
if c.PromptTemplate.ENTemplate.ActionInput == "" {
c.PromptTemplate.ENTemplate.ActionInput = "the input to the action"
}
c.PromptTemplate.ENTemplate.Observation = gjson.Get("promptTemplate.enTemplate.observation").String()
if c.PromptTemplate.ENTemplate.Observation == "" {
c.PromptTemplate.ENTemplate.Observation = "the result of the action"
}
c.PromptTemplate.ENTemplate.Thought1 = gjson.Get("promptTemplate.enTemplate.thought2").String()
if c.PromptTemplate.ENTemplate.Thought1 == "" {
c.PromptTemplate.ENTemplate.Thought1 = "I now know the final answer"
}
c.PromptTemplate.ENTemplate.FinalAnswer = gjson.Get("promptTemplate.enTemplate.finalAnswer").String()
if c.PromptTemplate.ENTemplate.FinalAnswer == "" {
c.PromptTemplate.ENTemplate.FinalAnswer = "the final answer to the original input question, please give the most direct answer directly in Chinese, not English, and do not add extra content."
}
c.PromptTemplate.ENTemplate.Begin = gjson.Get("promptTemplate.enTemplate.begin").String()
if c.PromptTemplate.ENTemplate.Begin == "" {
c.PromptTemplate.ENTemplate.Begin = "Begin! Remember to speak as a pirate when giving your final answer. Use lots of \"Arg\"s"
}
} else if c.PromptTemplate.Language == "CH" {
c.PromptTemplate.CHTemplate.Question = gjson.Get("promptTemplate.chTemplate.question").String()
if c.PromptTemplate.CHTemplate.Question == "" {
c.PromptTemplate.CHTemplate.Question = "你需要回答的输入问题"
}
c.PromptTemplate.CHTemplate.Thought1 = gjson.Get("promptTemplate.chTemplate.thought1").String()
if c.PromptTemplate.CHTemplate.Thought1 == "" {
c.PromptTemplate.CHTemplate.Thought1 = "你应该总是思考该做什么"
}
c.PromptTemplate.CHTemplate.ActionInput = gjson.Get("promptTemplate.chTemplate.actionInput").String()
if c.PromptTemplate.CHTemplate.ActionInput == "" {
c.PromptTemplate.CHTemplate.ActionInput = "行动的输入必须出现在Action后"
}
c.PromptTemplate.CHTemplate.Observation = gjson.Get("promptTemplate.chTemplate.observation").String()
if c.PromptTemplate.CHTemplate.Observation == "" {
c.PromptTemplate.CHTemplate.Observation = "行动的结果"
}
c.PromptTemplate.CHTemplate.Thought1 = gjson.Get("promptTemplate.chTemplate.thought2").String()
if c.PromptTemplate.CHTemplate.Thought1 == "" {
c.PromptTemplate.CHTemplate.Thought1 = "我现在知道最终答案"
}
c.PromptTemplate.CHTemplate.FinalAnswer = gjson.Get("promptTemplate.chTemplate.finalAnswer").String()
if c.PromptTemplate.CHTemplate.FinalAnswer == "" {
c.PromptTemplate.CHTemplate.FinalAnswer = "对原始输入问题的最终答案"
}
c.PromptTemplate.CHTemplate.Begin = gjson.Get("promptTemplate.chTemplate.begin").String()
if c.PromptTemplate.CHTemplate.Begin == "" {
c.PromptTemplate.CHTemplate.Begin = "再次重申,不要修改以上模板的字段名称,开始吧!"
}
}
}
func initLLMClient(gjson gjson.Result, c *PluginConfig) {
c.LLMInfo.APIKey = gjson.Get("llm.apiKey").String()
c.LLMInfo.ServiceName = gjson.Get("llm.serviceName").String()
c.LLMInfo.ServicePort = gjson.Get("llm.servicePort").Int()
c.LLMInfo.Domin = gjson.Get("llm.domain").String()
c.LLMInfo.Path = gjson.Get("llm.path").String()
c.LLMInfo.Model = gjson.Get("llm.model").String()
c.LLMInfo.MaxIterations = gjson.Get("llm.maxIterations").Int()
if c.LLMInfo.MaxIterations == 0 {
c.LLMInfo.MaxIterations = 15
}
c.LLMInfo.MaxExecutionTime = gjson.Get("llm.maxExecutionTime").Int()
if c.LLMInfo.MaxExecutionTime == 0 {
c.LLMInfo.MaxExecutionTime = 50000
}
c.LLMInfo.MaxTokens = gjson.Get("llm.maxTokens").Int()
if c.LLMInfo.MaxTokens == 0 {
c.LLMInfo.MaxTokens = 1000
}
c.LLMClient = wrapper.NewClusterClient(wrapper.FQDNCluster{
FQDN: c.LLMInfo.ServiceName,
Port: c.LLMInfo.ServicePort,
Host: c.LLMInfo.Domin,
})
}

View File

@@ -0,0 +1,46 @@
package dashscope
var MessageStore ChatMessages
func init() {
MessageStore = make(ChatMessages, 0)
MessageStore.Clear() //清理和初始化
}
type ChatMessages []Message
// 枚举出角色
const (
RoleUser = "user"
RoleAssistant = "assistant"
RoleSystem = "system"
)
func (cm *ChatMessages) Clear() {
*cm = make([]Message, 0) //重新初始化
}
// 添加角色和对应的prompt
func (cm *ChatMessages) AddFor(msg string, role string) {
*cm = append(*cm, Message{
Role: role,
Content: msg,
})
}
// 添加Assistant角色的prompt
func (cm *ChatMessages) AddForAssistant(msg string) {
cm.AddFor(msg, RoleAssistant)
}
// 添加System角色的prompt
func (cm *ChatMessages) AddForSystem(msg string) {
cm.AddFor(msg, RoleSystem)
}
// 添加User角色的prompt
func (cm *ChatMessages) AddForUser(msg string) {
cm.AddFor(msg, RoleUser)
}

View File

@@ -0,0 +1,70 @@
package dashscope
// DashScope embedding service: Request
type Request struct {
Model string `json:"model"`
Input Input `json:"input"`
Parameter Parameter `json:"parameters"`
}
type Input struct {
Texts []string `json:"texts"`
}
type Parameter struct {
TextType string `json:"text_type"`
}
// DashScope embedding service: Response
type Response struct {
Output Output `json:"output"`
Usage Usage `json:"usage"`
RequestID string `json:"request_id"`
}
type Output struct {
Embeddings []Embedding `json:"embeddings"`
}
type Embedding struct {
Embedding []float32 `json:"embedding"`
TextIndex int32 `json:"text_index"`
}
type Usage struct {
TotalTokens int32 `json:"total_tokens"`
}
// completion
type Completion struct {
Model string `json:"model"`
Messages []Message `json:"messages"`
MaxTokens int64 `json:"max_tokens"`
}
type Message struct {
Role string `json:"role"`
Content string `json:"content"`
}
type CompletionResponse struct {
Choices []Choice `json:"choices"`
Object string `json:"object"`
Usage CompletionUsage `json:"usage"`
Created string `json:"created"`
SystemFingerprint string `json:"system_fingerprint"`
Model string `json:"model"`
ID string `json:"id"`
}
type Choice struct {
Message Message `json:"message"`
FinishReason string `json:"finish_reason"`
Index int `json:"index"`
}
type CompletionUsage struct {
PromptTokens int `json:"prompt_tokens"`
CompletionTokens int `json:"completion_tokens"`
TotalTokens int `json:"total_tokens"`
}

View File

@@ -0,0 +1,19 @@
module github.com/alibaba/higress/plugins/wasm-go/extensions/ai-agent
go 1.19
require (
github.com/alibaba/higress/plugins/wasm-go v1.4.2
github.com/higress-group/proxy-wasm-go-sdk v0.0.0-20240711023527-ba358c48772f
github.com/tidwall/gjson v1.17.3
gopkg.in/yaml.v2 v2.4.0
)
require (
github.com/google/uuid v1.3.0 // indirect
github.com/higress-group/nottinygc v0.0.0-20231101025119-e93c4c2f8520 // indirect
github.com/magefile/mage v1.14.0 // indirect
github.com/tidwall/match v1.1.1 // indirect
github.com/tidwall/pretty v1.2.0 // indirect
github.com/tidwall/resp v0.1.1 // indirect
)

View File

@@ -0,0 +1,26 @@
github.com/alibaba/higress/plugins/wasm-go v1.4.2 h1:gH7OIGXm4wtW5Vo7L2deMPqF7OVWNESDHv1CaaTGu6s=
github.com/alibaba/higress/plugins/wasm-go v1.4.2/go.mod h1:359don/ahMxpfeLMzr29Cjwcu8IywTTDUzWlBPRNLHw=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/google/uuid v1.3.0 h1:t6JiXgmwXMjEs8VusXIJk2BXHsn+wx8BZdTaoZ5fu7I=
github.com/google/uuid v1.3.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/higress-group/nottinygc v0.0.0-20231101025119-e93c4c2f8520 h1:IHDghbGQ2DTIXHBHxWfqCYQW1fKjyJ/I7W1pMyUDeEA=
github.com/higress-group/nottinygc v0.0.0-20231101025119-e93c4c2f8520/go.mod h1:Nz8ORLaFiLWotg6GeKlJMhv8cci8mM43uEnLA5t8iew=
github.com/higress-group/proxy-wasm-go-sdk v0.0.0-20240711023527-ba358c48772f h1:ZIiIBRvIw62gA5MJhuwp1+2wWbqL9IGElQ499rUsYYg=
github.com/higress-group/proxy-wasm-go-sdk v0.0.0-20240711023527-ba358c48772f/go.mod h1:hNFjhrLUIq+kJ9bOcs8QtiplSQ61GZXtd2xHKx4BYRo=
github.com/magefile/mage v1.14.0 h1:6QDX3g6z1YvJ4olPhT1wksUcSa/V0a1B+pJb73fBjyo=
github.com/magefile/mage v1.14.0/go.mod h1:z5UZb/iS3GoOSn0JgWuiw7dxlurVYTu+/jHXqQg881A=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk=
github.com/tidwall/gjson v1.17.3 h1:bwWLZU7icoKRG+C+0PNwIKC6FCJO/Q3p2pZvuP0jN94=
github.com/tidwall/gjson v1.17.3/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk=
github.com/tidwall/match v1.1.1 h1:+Ho715JplO36QYgwN9PGYNhgZvoUSc9X2c80KVTi+GA=
github.com/tidwall/match v1.1.1/go.mod h1:eRSPERbgtNPcGhD8UCthc6PmLEQXEWd3PRB5JTxsfmM=
github.com/tidwall/pretty v1.2.0 h1:RWIZEg2iJ8/g6fDDYzMpobmaoGh5OLl4AXtGUGPcqCs=
github.com/tidwall/pretty v1.2.0/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU=
github.com/tidwall/resp v0.1.1 h1:Ly20wkhqKTmDUPlyM1S7pWo5kk0tDu8OoC/vFArXmwE=
github.com/tidwall/resp v0.1.1/go.mod h1:3/FrruOBAxPTPtundW0VXgmsQ4ZBA0Aw714lVYgwFa0=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY=
gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=

View File

@@ -0,0 +1,372 @@
package main
import (
"encoding/json"
"fmt"
"net/http"
"regexp"
"strings"
"github.com/alibaba/higress/plugins/wasm-go/extensions/ai-agent/dashscope"
prompttpl "github.com/alibaba/higress/plugins/wasm-go/extensions/ai-agent/promptTpl"
"github.com/alibaba/higress/plugins/wasm-go/pkg/wrapper"
"github.com/higress-group/proxy-wasm-go-sdk/proxywasm"
"github.com/higress-group/proxy-wasm-go-sdk/proxywasm/types"
"github.com/tidwall/gjson"
)
// 用于统计函数的递归调用次数
const ToolCallsCount = "ToolCallsCount"
// react的正则规则
const ActionPattern = `Action:\s*(.*?)[.\n]`
const ActionInputPattern = `Action Input:\s*(.*)`
const FinalAnswerPattern = `Final Answer:(.*)`
func main() {
wrapper.SetCtx(
"ai-agent",
wrapper.ParseConfigBy(parseConfig),
wrapper.ProcessRequestHeadersBy(onHttpRequestHeaders),
wrapper.ProcessRequestBodyBy(onHttpRequestBody),
wrapper.ProcessResponseHeadersBy(onHttpResponseHeaders),
wrapper.ProcessResponseBodyBy(onHttpResponseBody),
)
}
func parseConfig(gjson gjson.Result, c *PluginConfig, log wrapper.Log) error {
initResponsePromptTpl(gjson, c)
err := initAPIs(gjson, c)
if err != nil {
return err
}
initReActPromptTpl(gjson, c)
initLLMClient(gjson, c)
return nil
}
func onHttpRequestHeaders(ctx wrapper.HttpContext, config PluginConfig, log wrapper.Log) types.Action {
return types.ActionContinue
}
func firstReq(config PluginConfig, prompt string, rawRequest Request, log wrapper.Log) types.Action {
log.Debugf("[onHttpRequestBody] firstreq:%s", prompt)
var userMessage Message
userMessage.Role = "user"
userMessage.Content = prompt
newMessages := []Message{userMessage}
rawRequest.Messages = newMessages
//replace old message and resume request qwen
newbody, err := json.Marshal(rawRequest)
if err != nil {
return types.ActionContinue
} else {
log.Debugf("[onHttpRequestBody] newRequestBody: ", string(newbody))
err := proxywasm.ReplaceHttpRequestBody(newbody)
if err != nil {
log.Debug("替换失败")
proxywasm.SendHttpResponse(200, [][2]string{{"content-type", "application/json; charset=utf-8"}}, []byte(fmt.Sprintf(config.ReturnResponseTemplate, "替换失败"+err.Error())), -1)
}
log.Debug("[onHttpRequestBody] request替换成功")
return types.ActionContinue
}
}
func onHttpRequestBody(ctx wrapper.HttpContext, config PluginConfig, body []byte, log wrapper.Log) types.Action {
log.Debug("onHttpRequestBody start")
defer log.Debug("onHttpRequestBody end")
//拿到请求
var rawRequest Request
err := json.Unmarshal(body, &rawRequest)
if err != nil {
log.Debugf("[onHttpRequestBody] body json umarshal err: ", err.Error())
return types.ActionContinue
}
log.Debugf("onHttpRequestBody rawRequest: %v", rawRequest)
//获取用户query
var query string
messageLength := len(rawRequest.Messages)
log.Debugf("[onHttpRequestBody] messageLength: %s\n", messageLength)
if messageLength > 0 {
query = rawRequest.Messages[messageLength-1].Content
log.Debugf("[onHttpRequestBody] query: %s\n", query)
} else {
return types.ActionContinue
}
if query == "" {
log.Debug("parse query from request body failed")
return types.ActionContinue
}
//拼装agent prompt模板
tool_desc := make([]string, 0)
tool_names := make([]string, 0)
for _, apiParam := range config.APIParam {
for _, tool_param := range apiParam.Tool_Param {
tool_desc = append(tool_desc, fmt.Sprintf(prompttpl.TOOL_DESC, tool_param.ToolName, tool_param.Description, tool_param.Description, tool_param.Description, tool_param.Parameter), "\n")
tool_names = append(tool_names, tool_param.ToolName)
}
}
var prompt string
if config.PromptTemplate.Language == "CH" {
prompt = fmt.Sprintf(prompttpl.CH_Template,
tool_desc,
config.PromptTemplate.CHTemplate.Question,
config.PromptTemplate.CHTemplate.Thought1,
tool_names,
config.PromptTemplate.CHTemplate.ActionInput,
config.PromptTemplate.CHTemplate.Observation,
config.PromptTemplate.CHTemplate.Thought2,
config.PromptTemplate.CHTemplate.FinalAnswer,
config.PromptTemplate.CHTemplate.Begin,
query)
} else {
prompt = fmt.Sprintf(prompttpl.EN_Template,
tool_desc,
config.PromptTemplate.ENTemplate.Question,
config.PromptTemplate.ENTemplate.Thought1,
tool_names,
config.PromptTemplate.ENTemplate.ActionInput,
config.PromptTemplate.ENTemplate.Observation,
config.PromptTemplate.ENTemplate.Thought2,
config.PromptTemplate.ENTemplate.FinalAnswer,
config.PromptTemplate.ENTemplate.Begin,
query)
}
ctx.SetContext(ToolCallsCount, 0)
//清理历史对话记录
dashscope.MessageStore.Clear()
//将请求加入到历史对话存储器中
dashscope.MessageStore.AddForUser(prompt)
//开始第一次请求
ret := firstReq(config, prompt, rawRequest, log)
return ret
}
func onHttpResponseHeaders(ctx wrapper.HttpContext, config PluginConfig, log wrapper.Log) types.Action {
log.Debug("onHttpResponseHeaders start")
defer log.Debug("onHttpResponseHeaders end")
return types.ActionContinue
}
func toolsCallResult(ctx wrapper.HttpContext, config PluginConfig, content string, rawResponse Response, log wrapper.Log, statusCode int, responseBody []byte) {
if statusCode != http.StatusOK {
log.Debugf("statusCode: %d\n", statusCode)
}
log.Info("========函数返回结果========")
log.Infof(string(responseBody))
observation := "Observation: " + string(responseBody)
dashscope.MessageStore.AddForUser(observation)
completion := dashscope.Completion{
Model: config.LLMInfo.Model,
Messages: dashscope.MessageStore,
MaxTokens: config.LLMInfo.MaxTokens,
}
headers := [][2]string{{"Content-Type", "application/json"}, {"Authorization", "Bearer " + config.LLMInfo.APIKey}}
completionSerialized, _ := json.Marshal(completion)
err := config.LLMClient.Post(
config.LLMInfo.Path,
headers,
completionSerialized,
func(statusCode int, responseHeaders http.Header, responseBody []byte) {
//得到gpt的返回结果
var responseCompletion dashscope.CompletionResponse
_ = json.Unmarshal(responseBody, &responseCompletion)
log.Infof("[toolsCall] content: %s\n", responseCompletion.Choices[0].Message.Content)
if responseCompletion.Choices[0].Message.Content != "" {
retType := toolsCall(ctx, config, responseCompletion.Choices[0].Message.Content, rawResponse, log)
if retType == types.ActionContinue {
//得到了Final Answer
var assistantMessage Message
assistantMessage.Role = "assistant"
startIndex := strings.Index(responseCompletion.Choices[0].Message.Content, "Final Answer:")
if startIndex != -1 {
startIndex += len("Final Answer:") // 移动到"Final Answer:"之后的位置
extractedText := responseCompletion.Choices[0].Message.Content[startIndex:]
assistantMessage.Content = extractedText
}
rawResponse.Choices[0].Message = assistantMessage
newbody, err := json.Marshal(rawResponse)
if err != nil {
proxywasm.ResumeHttpResponse()
return
} else {
log.Infof("[onHttpResponseBody] newResponseBody: ", string(newbody))
proxywasm.ReplaceHttpResponseBody(newbody)
log.Debug("[onHttpResponseBody] response替换成功")
proxywasm.ResumeHttpResponse()
}
}
} else {
proxywasm.ResumeHttpRequest()
}
}, uint32(config.LLMInfo.MaxExecutionTime))
if err != nil {
log.Debugf("[onHttpRequestBody] completion err: %s", err.Error())
proxywasm.ResumeHttpRequest()
}
}
func toolsCall(ctx wrapper.HttpContext, config PluginConfig, content string, rawResponse Response, log wrapper.Log) types.Action {
dashscope.MessageStore.AddForAssistant(content)
//得到最终答案
regexPattern := regexp.MustCompile(FinalAnswerPattern)
finalAnswer := regexPattern.FindStringSubmatch(content)
if len(finalAnswer) > 1 {
return types.ActionContinue
}
count := ctx.GetContext(ToolCallsCount).(int)
count++
log.Debugf("toolCallsCount:%d, config.LLMInfo.MaxIterations=%d\n", count, config.LLMInfo.MaxIterations)
//函数递归调用次数,达到了预设的循环次数,强制结束
if int64(count) > config.LLMInfo.MaxIterations {
ctx.SetContext(ToolCallsCount, 0)
return types.ActionContinue
} else {
ctx.SetContext(ToolCallsCount, count)
}
//没得到最终答案
regexAction := regexp.MustCompile(ActionPattern)
regexActionInput := regexp.MustCompile(ActionInputPattern)
action := regexAction.FindStringSubmatch(content)
actionInput := regexActionInput.FindStringSubmatch(content)
if len(action) > 1 && len(actionInput) > 1 {
var url string
var headers [][2]string
var apiClient wrapper.HttpClient
var method string
var reqBody []byte
var key string
for i, apiParam := range config.APIParam {
for _, tool_param := range apiParam.Tool_Param {
if action[1] == tool_param.ToolName {
log.Infof("calls %s\n", tool_param.ToolName)
log.Infof("actionInput[1]: %s", actionInput[1])
//将大模型需要的参数反序列化
var data map[string]interface{}
if err := json.Unmarshal([]byte(actionInput[1]), &data); err != nil {
log.Debugf("Error: %s\n", err.Error())
return types.ActionContinue
}
method = tool_param.Method
//key or header组装
if apiParam.APIKey.Name != "" {
if apiParam.APIKey.In == "query" { //query类型的key要放到url中
headers = nil
key = "?" + apiParam.APIKey.Name + "=" + apiParam.APIKey.Value
} else if apiParam.APIKey.In == "header" { //header类型的key放在header中
headers = [][2]string{{"Content-Type", "application/json"}, {"Authorization", apiParam.APIKey.Name + " " + apiParam.APIKey.Value}}
}
}
if method == "GET" {
//query组装
var args string
for i, param := range tool_param.ParamName { //从参数列表中取出参数
if i == 0 && apiParam.APIKey.In != "query" {
args = "?" + param + "=%s"
args = fmt.Sprintf(args, data[param])
} else {
args = args + "&" + param + "=%s"
args = fmt.Sprintf(args, data[param])
}
}
//url组装
url = apiParam.URL + tool_param.Path + key + args
} else if method == "POST" {
reqBody = nil
//json参数组装
jsonData, err := json.Marshal(data)
if err != nil {
log.Debugf("Error: %s\n", err.Error())
return types.ActionContinue
}
reqBody = jsonData
//url组装
url = apiParam.URL + tool_param.Path + key
}
log.Infof("url: %s\n", url)
apiClient = config.APIClient[i]
break
}
}
}
if apiClient != nil {
err := apiClient.Call(
method,
url,
headers,
reqBody,
func(statusCode int, responseHeaders http.Header, responseBody []byte) {
toolsCallResult(ctx, config, content, rawResponse, log, statusCode, responseBody)
}, 50000)
if err != nil {
log.Debugf("tool calls error: %s\n", err.Error())
proxywasm.ResumeHttpRequest()
}
} else {
return types.ActionContinue
}
}
return types.ActionPause
}
// 从response接收到firstreq的大模型返回
func onHttpResponseBody(ctx wrapper.HttpContext, config PluginConfig, body []byte, log wrapper.Log) types.Action {
log.Debugf("onHttpResponseBody start")
defer log.Debugf("onHttpResponseBody end")
//初始化接收gpt返回内容的结构体
var rawResponse Response
err := json.Unmarshal(body, &rawResponse)
if err != nil {
log.Debugf("[onHttpResponseBody] body to json err: %s", err.Error())
return types.ActionContinue
}
log.Infof("first content: %s\n", rawResponse.Choices[0].Message.Content)
//如果gpt返回的内容不是空的
if rawResponse.Choices[0].Message.Content != "" {
//进入agent的循环思考工具调用的过程中
return toolsCall(ctx, config, rawResponse.Choices[0].Message.Content, rawResponse, log)
} else {
return types.ActionContinue
}
}

View File

@@ -0,0 +1,93 @@
package prompttpl
// input param
// {name_for_model}
// {description_for_model}
// {description_for_model}
// {description_for_model}
// {parameters}
const TOOL_DESC = `
%s: Call this tool to interact with the %s API. What is the %s API useful for? %s
Parameters:
%s
Format the arguments as a JSON object.`
/*
Answer the following questions as best you can, but speaking as a pirate might speak. You have access to the following tools:
%s
Use the following format:
Question: the input question you must answer
Thought: you should always think about what to do
Action: the action to take, should be one of %s
Action Input: the input to the action
Observation: the result of the action
... (this Thought/Action/Action Input/Observation can repeat N times)
Thought: I now know the final answer
Final Answer: the final answer to the original input question, please give the most direct answer directly in Chinese, not English, and do not add extra content.
Begin! Remember to speak as a pirate when giving your final answer. Use lots of "Arg"s
Question: %s
*/
const EN_Template = `
Answer the following questions as best you can, but speaking as a pirate might speak. You have access to the following tools:
%s
Use the following format:
Question: %s
Thought: %s
Action: the action to take, should be one of %s
Action Input: %s
Observation: %s
... (this Thought/Action/Action Input/Observation can repeat N times)
Thought: %s
Final Answer: %s
%s
Question: %s
`
/*
尽你所能回答以下问题。你可以使用以下工具:
%s
请使用以下格式其中Action字段后必须跟着Action Input字段并且不要将Action Input替换成Input或者tool等字段不能出现格式以外的字段名每个字段在每个轮次只出现一次
Question: 你需要回答的输入问题
Thought: 你应该总是思考该做什么
Action: 要采取的动作,动作只能是%s中的一个 ,一定不要加入其它内容
Action Input: 行动的输入必须出现在Action后。
Observation: 行动的结果
...这个Thought/Action/Action Input/Observation可以重复N次
Thought: 我现在知道最终答案
Final Answer: 对原始输入问题的最终答案
再次重申,不要修改以上模板的字段名称,开始吧!
Question: %s
*/
const CH_Template = `
尽你所能回答以下问题。你可以使用以下工具:
%s
请使用以下格式其中Action字段后必须跟着Action Input字段并且不要将Action Input替换成Input或者tool等字段不能出现格式以外的字段名每个字段在每个轮次只出现一次
Question: %s
Thought: %s
Action: 要采取的动作,动作只能是%s中的一个 ,一定不要加入其它内容
Action Input: %s
Observation: %s
...这个Thought/Action/Action Input/Observation可以重复N次
Thought: %s
Final Answer: %s
%s
Question: %s
`

View File

@@ -66,4 +66,70 @@ curl http://localhost/test \
}
]
}
```
```
# 基于geo-ip插件的能力扩展AI提示词装饰器插件携带用户地理位置信息
如果需要在LLM的请求前后加入用户地理位置信息请确保同时开启geo-ip插件和AI提示词装饰器插件。并且在相同的请求处理阶段里geo-ip插件的优先级必须高于AI提示词装饰器插件。首先geo-ip插件会根据用户ip计算出用户的地理位置信息然后通过请求属性传递给后续插件。比如在默认阶段里geo-ip插件的priority配置1000ai-prompt-decorator插件的priority配置500。
geo-ip插件配置示例
```yaml
ipProtocal: "ipv4"
```
AI提示词装饰器插件的配置示例如下
```yaml
prepend:
- role: system
content: "提问用户当前的地理位置信息是,国家:${geo-country},省份:${geo-province}, 城市:${geo-city}"
append:
- role: user
content: "每次回答完问题,尝试进行反问"
```
使用以上配置发起请求:
```bash
curl http://localhost/test \
-H "content-type: application/json" \
-H "x-forwarded-for: 87.254.207.100,4.5.6.7" \
-d '{
"model": "gpt-3.5-turbo",
"messages": [
{
"role": "user",
"content": "今天天气怎么样?"
}
]
}'
```
经过插件处理后,实际请求为:
```bash
curl http://localhost/test \
-H "content-type: application/json" \
-H "x-forwarded-for: 87.254.207.100,4.5.6.7" \
-d '{
"model": "gpt-3.5-turbo",
"messages": [
{
"role": "system",
"content": "提问用户当前的地理位置信息是,国家:中国,省份:北京, 城市:北京"
},
{
"role": "user",
"content": "今天天气怎么样?"
},
{
"role": "user",
"content": "每次回答完问题,尝试进行反问"
}
]
}'
```

View File

@@ -2,6 +2,8 @@ package main
import (
"encoding/json"
"fmt"
"strings"
"github.com/alibaba/higress/plugins/wasm-go/pkg/wrapper"
"github.com/higress-group/proxy-wasm-go-sdk/proxywasm"
@@ -38,10 +40,42 @@ func onHttpRequestHeaders(ctx wrapper.HttpContext, config AIPromptDecoratorConfi
return types.ActionContinue
}
func replaceVariable(variable string, entry *Message) (*Message, error) {
key := fmt.Sprintf("${%s}", variable)
if strings.Contains(entry.Content, key) {
value, err := proxywasm.GetProperty([]string{variable})
if err != nil {
return nil, err
}
entry.Content = strings.ReplaceAll(entry.Content, key, string(value))
}
return entry, nil
}
func decorateGeographicPrompt(entry *Message) (*Message, error) {
geoArr := []string{"geo-country", "geo-province", "geo-city", "geo-isp"}
var err error
for _, geo := range geoArr {
entry, err = replaceVariable(geo, entry)
if err != nil {
return nil, err
}
}
return entry, nil
}
func onHttpRequestBody(ctx wrapper.HttpContext, config AIPromptDecoratorConfig, body []byte, log wrapper.Log) types.Action {
messageJson := `{"messages":[]}`
for _, entry := range config.Prepend {
entry, err := decorateGeographicPrompt(&entry)
if err != nil {
log.Errorf("Failed to decorate geographic prompt in prepend, error: %v", err)
return types.ActionContinue
}
msg, err := json.Marshal(entry)
if err != nil {
log.Errorf("Failed to add prepend message, error: %v", err)
@@ -60,6 +94,12 @@ func onHttpRequestBody(ctx wrapper.HttpContext, config AIPromptDecoratorConfig,
}
for _, entry := range config.Append {
entry, err := decorateGeographicPrompt(&entry)
if err != nil {
log.Errorf("Failed to decorate geographic prompt in append, error: %v", err)
return types.ActionContinue
}
msg, err := json.Marshal(entry)
if err != nil {
log.Errorf("Failed to add prepend message, error: %v", err)

View File

@@ -34,6 +34,7 @@ description: AI 代理插件配置参考
| `modelMapping` | map of string | 非必填 | - | AI 模型映射表,用于将请求中的模型名称映射为服务提供商支持模型名称。<br/>1. 支持前缀匹配。例如用 "gpt-3-*" 匹配所有名称以“gpt-3-”开头的模型;<br/>2. 支持使用 "*" 为键来配置通用兜底映射关系;<br/>3. 如果映射的目标名称为空字符串 "",则表示保留原模型名称。 |
| `protocol` | string | 非必填 | - | 插件对外提供的 API 接口契约。目前支持以下取值openai默认值使用 OpenAI 的接口契约、original使用目标服务提供商的原始接口契约 |
| `context` | object | 非必填 | - | 配置 AI 对话上下文信息 |
| `customSettings` | array of customSetting | 非必填 | - | 为AI请求指定覆盖或者填充参数 |
`context`的配置字段说明如下:
@@ -43,6 +44,33 @@ description: AI 代理插件配置参考
| `serviceName` | string | 必填 | - | URL 所对应的 Higress 后端服务完整名称 |
| `servicePort` | number | 必填 | - | URL 所对应的 Higress 后端服务访问端口 |
`customSettings`的配置字段说明如下:
| 名称 | 数据类型 | 填写要求 | 默认值 | 描述 |
| ----------- | --------------------- | -------- | ------ | ---------------------------------------------------------------------------------------------------------------------------- |
| `name` | string | 必填 | - | 想要设置的参数的名称,例如`max_tokens` |
| `value` | string/int/float/bool | 必填 | - | 想要设置的参数的值例如0 |
| `mode` | string | 非必填 | "auto" | 参数设置的模式,可以设置为"auto"或者"raw",如果为"auto"则会自动根据协议对参数名做改写,如果为"raw"则不会有任何改写和限制检查 |
| `overwrite` | bool | 非必填 | true | 如果为false则只在用户没有设置这个参数时填充参数否则会直接覆盖用户原有的参数设置 |
custom-setting会遵循如下表格根据`name`和协议来替换对应的字段,用户需要填写表格中`settingName`列中存在的值。例如用户将`name`设置为`max_tokens`在openai协议中会替换`max_tokens`在gemini中会替换`maxOutputTokens`
`none`表示该协议不支持此参数。如果`name`不在此表格中或者对应协议不支持此参数同时没有设置raw模式则配置不会生效。
| settingName | openai | baidu | spark | qwen | gemini | hunyuan | claude | minimax |
| ----------- | ----------- | ----------------- | ----------- | ----------- | --------------- | ----------- | ----------- | ------------------ |
| max_tokens | max_tokens | max_output_tokens | max_tokens | max_tokens | maxOutputTokens | none | max_tokens | tokens_to_generate |
| temperature | temperature | temperature | temperature | temperature | temperature | Temperature | temperature | temperature |
| top_p | top_p | top_p | none | top_p | topP | TopP | top_p | top_p |
| top_k | none | none | top_k | none | topK | none | top_k | none |
| seed | seed | none | none | seed | none | none | none | none |
如果启用了raw模式custom-setting会直接用输入的`name``value`去更改请求中的json内容而不对参数名称做任何限制和修改。
对于大多数协议custom-setting都会在json内容的根路径修改或者填充参数。对于`qwen`协议ai-proxy会在json的`parameters`子路径下做配置。对于`gemini`协议,则会在`generation_config`子路径下做配置。
### 提供商特有配置
#### OpenAI
@@ -52,6 +80,7 @@ OpenAI 所对应的 `type` 为 `openai`。它特有的配置字段如下:
| 名称 | 数据类型 | 填写要求 | 默认值 | 描述 |
|-------------------|----------|----------|--------|-------------------------------------------------------------------------------|
| `openaiCustomUrl` | string | 非必填 | - | 基于OpenAI协议的自定义后端URL例如: www.example.com/myai/v1/chat/completions |
| `responseJsonSchema` | object | 非必填 | - | 预先定义OpenAI响应需满足的Json Schema, 注意目前仅特定的几种模型支持该用法|
#### Azure OpenAI
@@ -105,6 +134,10 @@ Groq 所对应的 `type` 为 `groq`。它并无特有的配置字段。
文心一言所对应的 `type``baidu`。它并无特有的配置字段。
#### 360智脑
360智脑所对应的 `type``ai360`。它并无特有的配置字段。
#### MiniMax
MiniMax所对应的 `type``minimax`。它特有的配置字段如下:
@@ -165,6 +198,14 @@ Gemini 所对应的 `type` 为 `gemini`。它特有的配置字段如下:
| --------------------- | -------- | -------- |-----|-------------------------------------------------------------------------------------------------|
| `geminiSafetySetting` | map of string | 非必填 | - | Gemini AI内容过滤和安全级别设定。参考[Safety settings](https://ai.google.dev/gemini-api/docs/safety-settings) |
#### DeepL
DeepL 所对应的 `type``deepl`。它特有的配置字段如下:
| 名称 | 数据类型 | 填写要求 | 默认值 | 描述 |
| ------------ | -------- | -------- | ------ | ---------------------------- |
| `targetLang` | string | 必填 | - | DeepL 翻译服务需要的目标语种 |
## 用法示例
### 使用 OpenAI 协议代理 Azure OpenAI 服务
@@ -281,6 +322,7 @@ provider:
'gpt-35-turbo': "qwen-plus"
'gpt-4-turbo': "qwen-max"
'gpt-4-*': "qwen-max"
'gpt-4o': "qwen-vl-plus"
'text-embedding-v1': 'text-embedding-v1'
'*': "qwen-turbo"
```
@@ -289,7 +331,111 @@ provider:
URL: http://your-domain/v1/chat/completions
请求
请求示例
```json
{
"model": "gpt-3",
"messages": [
{
"role": "user",
"content": "你好,你是谁?"
}
],
"temperature": 0.3
}
```
响应示例:
```json
{
"id": "c2518bd3-0f46-97d1-be34-bb5777cb3108",
"choices": [
{
"index": 0,
"message": {
"role": "assistant",
"content": "我是通义千问由阿里云开发的AI助手。我可以回答各种问题、提供信息和与用户进行对话。有什么我可以帮助你的吗"
},
"finish_reason": "stop"
}
],
"created": 1715175072,
"model": "qwen-turbo",
"object": "chat.completion",
"usage": {
"prompt_tokens": 24,
"completion_tokens": 33,
"total_tokens": 57
}
}
```
**多模态模型 API 请求示例(适用于 `qwen-vl-plus` 和 `qwen-vl-max` 模型)**
URL: http://your-domain/v1/chat/completions
请求示例:
```json
{
"model": "gpt-4o",
"messages": [
{
"role": "user",
"content": [
{
"type": "image_url",
"image_url": {
"url": "https://dashscope.oss-cn-beijing.aliyuncs.com/images/dog_and_girl.jpeg"
}
},
{
"type": "text",
"text": "这个图片是哪里?"
}
]
}
],
"temperature": 0.3
}
```
响应示例:
```json
{
"id": "17c5955d-af9c-9f28-bbde-293a9c9a3515",
"choices": [
{
"index": 0,
"message": {
"role": "assistant",
"content": [
{
"text": "这张照片显示的是一位女士和一只狗在海滩上。由于我无法获取具体的地理位置信息,所以不能确定这是哪个地方的海滩。但是从视觉内容来看,它可能是一个位于沿海地区的沙滩海岸线,并且有海浪拍打着岸边。这样的场景在全球许多美丽的海滨地区都可以找到。如果您需要更精确的信息,请提供更多的背景或细节描述。"
}
]
},
"finish_reason": "stop"
}
],
"created": 1723949230,
"model": "qwen-vl-plus",
"object": "chat.completion",
"usage": {
"prompt_tokens": 1279,
"completion_tokens": 78
}
}
```
**文本向量请求示例**
URL: http://your-domain/v1/embeddings
请求示例:
```json
{
@@ -298,7 +444,7 @@ URL: http://your-domain/v1/chat/completions
}
```
响应示例:
响应示例:
```json
{
@@ -330,47 +476,6 @@ URL: http://your-domain/v1/chat/completions
}
```
**请求示例**
URL: http://your-domain/v1/embeddings
示例请求内容:
```json
{
"model": "text-embedding-v1",
"input": [
"Hello world!"
]
}
```
示例响应内容:
```json
{
"id": "c2518bd3-0f46-97d1-be34-bb5777cb3108",
"choices": [
{
"index": 0,
"message": {
"role": "assistant",
"content": "我是通义千问由阿里云开发的AI助手。我可以回答各种问题、提供信息和与用户进行对话。有什么我可以帮助你的吗"
},
"finish_reason": "stop"
}
],
"created": 1715175072,
"model": "qwen-turbo",
"object": "chat.completion",
"usage": {
"prompt_tokens": 24,
"completion_tokens": 33,
"total_tokens": 57
}
}
```
### 使用通义千问配合纯文本上下文信息
使用通义千问服务,同时配置纯文本上下文信息。
@@ -839,6 +944,77 @@ provider:
}
```
### 使用 OpenAI 协议代理360智脑服务
**配置信息**
```yaml
provider:
type: ai360
apiTokens:
- "YOUR_MINIMAX_API_TOKEN"
modelMapping:
"gpt-4o": "360gpt-turbo-responsibility-8k"
"gpt-4": "360gpt2-pro"
"gpt-3.5": "360gpt-turbo"
"*": "360gpt-pro"
```
**请求示例**
```json
{
"model": "gpt-4o",
"messages": [
{
"role": "system",
"content": "你是一个专业的开发人员!"
},
{
"role": "user",
"content": "你好,你是谁?"
}
]
}
```
**响应示例**
```json
{
"choices": [
{
"message": {
"role": "assistant",
"content": "你好我是360智脑一个大型语言模型。我可以帮助回答各种问题、提供信息、进行对话等。有什么可以帮助你的吗"
},
"finish_reason": "",
"index": 0
}
],
"created": 1724257207,
"id": "5e5c94a2-d989-40b5-9965-5b971db941fe",
"model": "360gpt-turbo",
"object": "",
"usage": {
"completion_tokens": 33,
"prompt_tokens": 24,
"total_tokens": 57
},
"messages": [
{
"role": "system",
"content": "你是一个专业的开发人员!"
},
{
"role": "user",
"content": "你好,你是谁?"
}
],
"context": null
}
```
### 使用 OpenAI 协议代理 Cloudflare Workers AI 服务
**配置信息**
@@ -1008,6 +1184,59 @@ provider:
}
```
### 使用 OpenAI 协议代理 DeepL 文本翻译服务
**配置信息**
```yaml
provider:
type: deepl
apiTokens:
- "YOUR_DEEPL_API_TOKEN"
targetLang: "ZH"
```
**请求示例**
此处 `model` 表示 DeepL 的服务类型,只能填 `Free``Pro``content` 中设置需要翻译的文本;在 `role: system``content` 中可以包含可能影响翻译但本身不会被翻译的上下文,例如翻译产品名称时,可以将产品描述作为上下文传递,这种额外的上下文可能会提高翻译的质量。
```json
{
"model": "Free",
"messages": [
{
"role": "system",
"content": "money"
},
{
"content": "sit by the bank"
},
{
"content": "a bank in China"
}
]
}
```
**响应示例**
```json
{
"choices": [
{
"index": 0,
"message": { "name": "EN", "role": "assistant", "content": "坐庄" }
},
{
"index": 1,
"message": { "name": "EN", "role": "assistant", "content": "中国银行" }
}
],
"created": 1722747752,
"model": "Free",
"object": "chat.completion",
"usage": {}
}
```
## 完整配置示例
### Kubernetes 示例

View File

@@ -50,3 +50,7 @@ func (c *PluginConfig) Complete() error {
func (c *PluginConfig) GetProvider() provider.Provider {
return c.provider
}
func (c *PluginConfig) GetProviderConfig() provider.ProviderConfig {
return c.providerConfig
}

View File

@@ -15,12 +15,13 @@ require (
require (
github.com/davecgh/go-spew v1.1.1 // indirect
github.com/google/uuid v1.3.0 // indirect
github.com/google/uuid v1.3.0
github.com/higress-group/nottinygc v0.0.0-20231101025119-e93c4c2f8520 // indirect
github.com/magefile/mage v1.14.0 // indirect
github.com/pmezard/go-difflib v1.0.0 // indirect
github.com/tidwall/match v1.1.1 // indirect
github.com/tidwall/pretty v1.2.0 // indirect
github.com/tidwall/resp v0.1.1 // indirect
github.com/tidwall/sjson v1.2.5
gopkg.in/yaml.v3 v3.0.1 // indirect
)

View File

@@ -12,6 +12,7 @@ github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZb
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk=
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
github.com/tidwall/gjson v1.14.2/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk=
github.com/tidwall/gjson v1.14.3 h1:9jvXn7olKEHU1S9vwoMGliaT8jq1vJ7IH/n9zD9Dnlw=
github.com/tidwall/gjson v1.14.3/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk=
github.com/tidwall/match v1.1.1 h1:+Ho715JplO36QYgwN9PGYNhgZvoUSc9X2c80KVTi+GA=
@@ -20,6 +21,8 @@ github.com/tidwall/pretty v1.2.0 h1:RWIZEg2iJ8/g6fDDYzMpobmaoGh5OLl4AXtGUGPcqCs=
github.com/tidwall/pretty v1.2.0/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU=
github.com/tidwall/resp v0.1.1 h1:Ly20wkhqKTmDUPlyM1S7pWo5kk0tDu8OoC/vFArXmwE=
github.com/tidwall/resp v0.1.1/go.mod h1:3/FrruOBAxPTPtundW0VXgmsQ4ZBA0Aw714lVYgwFa0=
github.com/tidwall/sjson v1.2.5 h1:kLy8mja+1c9jlljvWTlSazM7cKDRfJuR/bOJhcY5NcY=
github.com/tidwall/sjson v1.2.5/go.mod h1:Fvgq9kS/6ociJEDnK0Fk1cpYF4FIW6ZF7LAe+6jwd28=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=

View File

@@ -75,15 +75,15 @@ func onHttpRequestHeader(ctx wrapper.HttpContext, pluginConfig config.PluginConf
// Disable the route re-calculation since the plugin may modify some headers related to the chosen route.
ctx.DisableReroute()
action, err := handler.OnRequestHeaders(ctx, apiName, log)
_, err := handler.OnRequestHeaders(ctx, apiName, log)
if err == nil {
if contentType, err := proxywasm.GetHttpRequestHeader("Content-Type"); err == nil && contentType != "" {
if wrapper.HasRequestBody() {
ctx.SetRequestBodyBufferLimit(defaultMaxBodyBytes)
// Always return types.HeaderStopIteration to support fallback routing,
// as long as onHttpRequestBody can be called.
return types.HeaderStopIteration
}
return action
return types.ActionContinue
}
_ = util.SendResponse(500, "ai-proxy.proc_req_headers_failed", util.MimeTypeTextPlain, fmt.Sprintf("failed to process request headers: %v", err))
return types.ActionContinue
@@ -104,12 +104,20 @@ func onHttpRequestBody(ctx wrapper.HttpContext, pluginConfig config.PluginConfig
if handler, ok := activeProvider.(provider.RequestBodyHandler); ok {
apiName, _ := ctx.GetContext(ctxKeyApiName).(provider.ApiName)
newBody, settingErr := pluginConfig.GetProviderConfig().ReplaceByCustomSettings(body)
if settingErr != nil {
_ = util.SendResponse(500, "ai-proxy.proc_req_body_failed", util.MimeTypeTextPlain, fmt.Sprintf("failed to rewrite request body by custom settings: %v", settingErr))
return types.ActionContinue
}
log.Debugf("[onHttpRequestBody] newBody=%s", newBody)
body = newBody
action, err := handler.OnRequestBody(ctx, apiName, body, log)
if err == nil {
return action
}
_ = util.SendResponse(500, "ai-proxy.proc_req_body_failed", util.MimeTypeTextPlain, fmt.Sprintf("failed to process request body: %v", err))
return types.ActionContinue
}
return types.ActionContinue
}
@@ -140,24 +148,18 @@ func onHttpResponseHeaders(ctx wrapper.HttpContext, pluginConfig config.PluginCo
return types.ActionContinue
}
contentType, err := proxywasm.GetHttpResponseHeader("Content-Type")
if err != nil || !strings.HasPrefix(contentType, "text/event-stream") {
if err != nil {
log.Errorf("unable to load content-type header from response: %v", err)
}
ctx.BufferResponseBody()
}
if handler, ok := activeProvider.(provider.ResponseHeadersHandler); ok {
apiName, _ := ctx.GetContext(ctxKeyApiName).(provider.ApiName)
action, err := handler.OnResponseHeaders(ctx, apiName, log)
if err == nil {
checkStream(&ctx, &log)
return action
}
_ = util.SendResponse(500, "ai-proxy.proc_resp_headers_failed", util.MimeTypeTextPlain, fmt.Sprintf("failed to process response headers: %v", err))
return types.ActionContinue
}
checkStream(&ctx, &log)
_, needHandleBody := activeProvider.(provider.ResponseBodyHandler)
_, needHandleStreamingBody := activeProvider.(provider.StreamingResponseBodyHandler)
if !needHandleBody && !needHandleStreamingBody {
@@ -223,3 +225,13 @@ func getOpenAiApiName(path string) provider.ApiName {
}
return ""
}
func checkStream(ctx *wrapper.HttpContext, log *wrapper.Log) {
contentType, err := proxywasm.GetHttpResponseHeader("Content-Type")
if err != nil || !strings.HasPrefix(contentType, "text/event-stream") {
if err != nil {
log.Errorf("unable to load content-type header from response: %v", err)
}
(*ctx).BufferResponseBody()
}
}

View File

@@ -0,0 +1,74 @@
package provider
import (
"errors"
"github.com/higress-group/proxy-wasm-go-sdk/proxywasm"
"github.com/higress-group/proxy-wasm-go-sdk/proxywasm/types"
"github.com/alibaba/higress/plugins/wasm-go/extensions/ai-proxy/util"
"github.com/alibaba/higress/plugins/wasm-go/pkg/wrapper"
)
// ai360Provider is the provider for 360 OpenAI service.
const (
ai360Domain = "api.360.cn"
)
type ai360ProviderInitializer struct {
}
type ai360Provider struct {
config ProviderConfig
contextCache *contextCache
}
func (m *ai360ProviderInitializer) ValidateConfig(config ProviderConfig) error {
if config.apiTokens == nil || len(config.apiTokens) == 0 {
return errors.New("no apiToken found in provider config")
}
return nil
}
func (m *ai360ProviderInitializer) CreateProvider(config ProviderConfig) (Provider, error) {
return &ai360Provider{
config: config,
contextCache: createContextCache(&config),
}, nil
}
func (m *ai360Provider) GetProviderType() string {
return providerTypeAi360
}
func (m *ai360Provider) OnRequestHeaders(ctx wrapper.HttpContext, apiName ApiName, log wrapper.Log) (types.Action, error) {
if apiName != ApiNameChatCompletion {
return types.ActionContinue, errUnsupportedApiName
}
_ = util.OverwriteRequestHost(ai360Domain)
_ = proxywasm.RemoveHttpRequestHeader("Accept-Encoding")
_ = proxywasm.RemoveHttpRequestHeader("Content-Length")
_ = proxywasm.ReplaceHttpRequestHeader("Authorization", m.config.GetRandomToken())
// Delay the header processing to allow changing streaming mode in OnRequestBody
return types.HeaderStopIteration, nil
}
func (m *ai360Provider) OnRequestBody(ctx wrapper.HttpContext, apiName ApiName, body []byte, log wrapper.Log) (types.Action, error) {
if apiName != ApiNameChatCompletion {
return types.ActionContinue, errUnsupportedApiName
}
request := &chatCompletionRequest{}
if err := decodeChatCompletionRequest(body, request); err != nil {
return types.ActionContinue, err
}
if request.Model == "" {
return types.ActionContinue, errors.New("missing model in chat completion request")
}
// 映射模型
mappedModel := getMappedModel(request.Model, m.config.modelMapping, log)
if mappedModel == "" {
return types.ActionContinue, errors.New("model becomes empty after applying the configured mapping")
}
ctx.SetContext(ctxKeyFinalRequestModel, mappedModel)
request.Model = mappedModel
return types.ActionContinue, replaceJsonRequestBody(request, log)
}

View File

@@ -253,7 +253,7 @@ func (b *baiduProvider) baiduTextGenRequest(request *chatCompletionRequest) *bai
}
for _, message := range request.Messages {
if message.Role == roleSystem {
baiduRequest.System = message.Content
baiduRequest.System = message.StringContent()
} else {
baiduRequest.Messages = append(baiduRequest.Messages, chatMessage{
Role: message.Role,

View File

@@ -274,7 +274,7 @@ func (c *claudeProvider) buildClaudeTextGenRequest(origRequest *chatCompletionRe
for _, message := range origRequest.Messages {
if message.Role == roleSystem {
claudeRequest.System = message.Content
claudeRequest.System = message.StringContent()
continue
}
claudeMessage := chatMessage{

View File

@@ -0,0 +1,137 @@
package provider
import (
"github.com/tidwall/gjson"
"github.com/tidwall/sjson"
)
const (
nameMaxTokens = "max_tokens"
nameTemperature = "temperature"
nameTopP = "top_p"
nameTopK = "top_k"
nameSeed = "seed"
)
var maxTokensMapping = map[string]string{
"openai": "max_tokens",
"baidu": "max_output_tokens",
"spark": "max_tokens",
"qwen": "max_tokens",
"gemini": "maxOutputTokens",
"claude": "max_tokens",
"minimax": "tokens_to_generate",
}
var temperatureMapping = map[string]string{
"openai": "temperature",
"baidu": "temperature",
"spark": "temperature",
"qwen": "temperature",
"gemini": "temperature",
"hunyuan": "Temperature",
"claude": "temperature",
"minimax": "temperature",
}
var topPMapping = map[string]string{
"openai": "top_p",
"baidu": "top_p",
"qwen": "top_p",
"gemini": "topP",
"hunyuan": "TopP",
"claude": "top_p",
"minimax": "top_p",
}
var topKMapping = map[string]string{
"spark": "top_k",
"gemini": "topK",
"claude": "top_k",
}
var seedMapping = map[string]string{
"openai": "seed",
"qwen": "seed",
}
var settingMapping = map[string]map[string]string{
nameMaxTokens: maxTokensMapping,
nameTemperature: temperatureMapping,
nameTopP: topPMapping,
nameTopK: topKMapping,
nameSeed: seedMapping,
}
type CustomSetting struct {
// @Title zh-CN 参数名称
// @Description zh-CN 想要设置的参数的名称例如max_tokens
name string
// @Title zh-CN 参数值
// @Description zh-CN 想要设置的参数的值例如0
value string
// @Title zh-CN 设置模式
// @Description zh-CN 参数设置的模式,可以设置为"auto"或者"raw",如果为"auto"则会根据 /plugins/wasm-go/extensions/ai-proxy/README.md中关于custom-setting部分的表格自动按照协议对参数名做改写如果为"raw"则不会有任何改写和限制检查
mode string
// @Title zh-CN json edit 模式
// @Description zh-CN 如果为false则只在用户没有设置这个参数时填充参数否则会直接覆盖用户原有的参数设置
overwrite bool
}
func (c *CustomSetting) FromJson(json gjson.Result) {
c.name = json.Get("name").String()
c.value = json.Get("value").Raw
if obj := json.Get("mode"); obj.Exists() {
c.mode = obj.String()
} else {
c.mode = "auto"
}
if obj := json.Get("overwrite"); obj.Exists() {
c.overwrite = obj.Bool()
} else {
c.overwrite = true
}
}
func (c *CustomSetting) Validate() bool {
return c.name != ""
}
func (c *CustomSetting) setInvalid() {
c.name = "" // set empty to represent invalid
}
func (c *CustomSetting) AdjustWithProtocol(protocol string) {
if !(c.mode == "raw") {
mapping, ok := settingMapping[c.name]
if ok {
c.name, ok = mapping[protocol]
}
if !ok {
c.setInvalid()
return
}
}
if protocol == providerTypeQwen {
c.name = "parameters." + c.name
}
if protocol == providerTypeGemini {
c.name = "generation_config." + c.name
}
}
func ReplaceByCustomSettings(body []byte, settings []CustomSetting) ([]byte, error) {
var err error
strBody := string(body)
for _, setting := range settings {
if !setting.overwrite && gjson.Get(strBody, setting.name).Exists() {
continue
}
strBody, err = sjson.SetRaw(strBody, setting.name, setting.value)
if err != nil {
break
}
}
return []byte(strBody), err
}

View File

@@ -0,0 +1,176 @@
package provider
import (
"encoding/json"
"errors"
"fmt"
"time"
"github.com/alibaba/higress/plugins/wasm-go/extensions/ai-proxy/util"
"github.com/alibaba/higress/plugins/wasm-go/pkg/wrapper"
"github.com/higress-group/proxy-wasm-go-sdk/proxywasm"
"github.com/higress-group/proxy-wasm-go-sdk/proxywasm/types"
)
// deeplProvider is the provider for DeepL service.
const (
deeplHostPro = "api.deepl.com"
deeplHostFree = "api-free.deepl.com"
deeplChatCompletionPath = "/v2/translate"
)
type deeplProviderInitializer struct {
}
type deeplProvider struct {
config ProviderConfig
contextCache *contextCache
}
// spec reference: https://developers.deepl.com/docs/v/zh/api-reference/translate/openapi-spec-for-text-translation
type deeplRequest struct {
// "Model" parameter is used to distinguish which service to use
Model string `json:"model,omitempty"`
Text []string `json:"text"`
SourceLang string `json:"source_lang,omitempty"`
TargetLang string `json:"target_lang"`
Context string `json:"context,omitempty"`
SplitSentences string `json:"split_sentences,omitempty"`
PreserveFormatting bool `json:"preserve_formatting,omitempty"`
Formality string `json:"formality,omitempty"`
GlossaryId string `json:"glossary_id,omitempty"`
TagHandling string `json:"tag_handling,omitempty"`
OutlineDetection bool `json:"outline_detection,omitempty"`
NonSplittingTags []string `json:"non_splitting_tags,omitempty"`
SplittingTags []string `json:"splitting_tags,omitempty"`
IgnoreTags []string `json:"ignore_tags,omitempty"`
}
type deeplResponse struct {
Translations []deeplResponseTranslation `json:"translations,omitempty"`
Message string `json:"message,omitempty"`
}
type deeplResponseTranslation struct {
DetectedSourceLanguage string `json:"detected_source_language"`
Text string `json:"text"`
}
func (d *deeplProviderInitializer) ValidateConfig(config ProviderConfig) error {
if config.targetLang == "" {
return errors.New("missing targetLang in deepl provider config")
}
return nil
}
func (d *deeplProviderInitializer) CreateProvider(config ProviderConfig) (Provider, error) {
return &deeplProvider{
config: config,
contextCache: createContextCache(&config),
}, nil
}
func (d *deeplProvider) GetProviderType() string {
return providerTypeDeepl
}
func (d *deeplProvider) OnRequestHeaders(ctx wrapper.HttpContext, apiName ApiName, log wrapper.Log) (types.Action, error) {
if apiName != ApiNameChatCompletion {
return types.ActionContinue, errUnsupportedApiName
}
_ = util.OverwriteRequestPath(deeplChatCompletionPath)
_ = util.OverwriteRequestAuthorization("DeepL-Auth-Key " + d.config.GetRandomToken())
_ = proxywasm.RemoveHttpRequestHeader("Content-Length")
_ = proxywasm.RemoveHttpRequestHeader("Accept-Encoding")
return types.HeaderStopIteration, nil
}
func (d *deeplProvider) OnRequestBody(ctx wrapper.HttpContext, apiName ApiName, body []byte, log wrapper.Log) (types.Action, error) {
if apiName != ApiNameChatCompletion {
return types.ActionContinue, errUnsupportedApiName
}
if d.config.protocol == protocolOriginal {
request := &deeplRequest{}
if err := json.Unmarshal(body, request); err != nil {
return types.ActionContinue, fmt.Errorf("unable to unmarshal request: %v", err)
}
if err := d.overwriteRequestHost(request.Model); err != nil {
return types.ActionContinue, err
}
ctx.SetContext(ctxKeyFinalRequestModel, request.Model)
return types.ActionContinue, replaceJsonRequestBody(request, log)
} else {
originRequest := &chatCompletionRequest{}
if err := decodeChatCompletionRequest(body, originRequest); err != nil {
return types.ActionContinue, err
}
if err := d.overwriteRequestHost(originRequest.Model); err != nil {
return types.ActionContinue, err
}
ctx.SetContext(ctxKeyFinalRequestModel, originRequest.Model)
deeplRequest := &deeplRequest{
Text: make([]string, 0),
TargetLang: d.config.targetLang,
}
for _, msg := range originRequest.Messages {
if msg.Role == roleSystem {
deeplRequest.Context = msg.StringContent()
} else {
deeplRequest.Text = append(deeplRequest.Text, msg.StringContent())
}
}
return types.ActionContinue, replaceJsonRequestBody(deeplRequest, log)
}
}
func (d *deeplProvider) OnResponseHeaders(ctx wrapper.HttpContext, apiName ApiName, log wrapper.Log) (types.Action, error) {
_ = proxywasm.RemoveHttpResponseHeader("Content-Length")
return types.ActionContinue, nil
}
func (d *deeplProvider) OnResponseBody(ctx wrapper.HttpContext, apiName ApiName, body []byte, log wrapper.Log) (types.Action, error) {
deeplResponse := &deeplResponse{}
if err := json.Unmarshal(body, deeplResponse); err != nil {
return types.ActionContinue, fmt.Errorf("unable to unmarshal deepl response: %v", err)
}
response := d.responseDeepl2OpenAI(ctx, deeplResponse)
return types.ActionContinue, replaceJsonResponseBody(response, log)
}
func (d *deeplProvider) responseDeepl2OpenAI(ctx wrapper.HttpContext, deeplResponse *deeplResponse) *chatCompletionResponse {
var choices []chatCompletionChoice
// Fail
if deeplResponse.Message != "" {
choices = make([]chatCompletionChoice, 1)
choices[0] = chatCompletionChoice{
Message: &chatMessage{Role: roleAssistant, Content: deeplResponse.Message},
Index: 0,
}
} else {
// Success
choices = make([]chatCompletionChoice, len(deeplResponse.Translations))
for idx, t := range deeplResponse.Translations {
choices[idx] = chatCompletionChoice{
Index: idx,
Message: &chatMessage{Role: roleAssistant, Content: t.Text, Name: t.DetectedSourceLanguage},
}
}
}
return &chatCompletionResponse{
Created: time.Now().UnixMilli() / 1000,
Object: objectChatCompletion,
Choices: choices,
Model: ctx.GetStringContext(ctxKeyFinalRequestModel, ""),
}
}
func (d *deeplProvider) overwriteRequestHost(model string) error {
if model == "Pro" {
_ = util.OverwriteRequestHost(deeplHostPro)
} else if model == "Free" {
_ = util.OverwriteRequestHost(deeplHostFree)
} else {
return errors.New(`deepl model should be "Free" or "Pro"`)
}
return nil
}

View File

@@ -4,13 +4,14 @@ import (
"encoding/json"
"errors"
"fmt"
"strings"
"time"
"github.com/alibaba/higress/plugins/wasm-go/extensions/ai-proxy/util"
"github.com/alibaba/higress/plugins/wasm-go/pkg/wrapper"
"github.com/google/uuid"
"github.com/higress-group/proxy-wasm-go-sdk/proxywasm"
"github.com/higress-group/proxy-wasm-go-sdk/proxywasm/types"
"strings"
"time"
)
// geminiProvider is the provider for google gemini/gemini flash service.
@@ -378,7 +379,7 @@ func (g *geminiProvider) buildGeminiChatRequest(request *chatCompletionRequest)
Role: message.Role,
Parts: []geminiPart{
{
Text: message.Content,
Text: message.StringContent(),
},
},
}

View File

@@ -447,7 +447,7 @@ func convertMessagesFromOpenAIToHunyuan(openAIMessages []chatMessage) []hunyuanC
for _, msg := range openAIMessages {
hunyuanChatMessages = append(hunyuanChatMessages, hunyuanChatMessage{
Role: msg.Role,
Content: msg.Content,
Content: msg.StringContent(),
})
}

View File

@@ -404,19 +404,19 @@ func (m *minimaxProvider) buildMinimaxChatCompletionV2Request(request *chatCompl
botName = determineName(message.Name, defaultBotName)
botSetting = append(botSetting, minimaxBotSetting{
BotName: botName,
Content: message.Content,
Content: message.StringContent(),
})
case roleAssistant:
messages = append(messages, minimaxMessage{
SenderType: senderTypeBot,
SenderName: determineName(message.Name, defaultBotName),
Text: message.Content,
Text: message.StringContent(),
})
case roleUser:
messages = append(messages, minimaxMessage{
SenderType: senderTypeUser,
SenderName: determineName(message.Name, defaultSenderName),
Text: message.Content,
Text: message.StringContent(),
})
}
}

View File

@@ -13,6 +13,9 @@ const (
eventResult = "result"
httpStatus200 = "200"
contentTypeText = "text"
contentTypeImageUrl = "image_url"
)
type chatCompletionRequest struct {
@@ -31,6 +34,7 @@ type chatCompletionRequest struct {
ToolChoice *toolChoice `json:"tool_choice,omitempty"`
User string `json:"user,omitempty"`
Stop []string `json:"stop,omitempty"`
ResponseFormat map[string]interface{} `json:"response_format,omitempty"`
}
type streamOptions struct {
@@ -79,12 +83,27 @@ type usage struct {
type chatMessage struct {
Name string `json:"name,omitempty"`
Role string `json:"role,omitempty"`
Content string `json:"content,omitempty"`
Content any `json:"content,omitempty"`
ToolCalls []toolCall `json:"tool_calls,omitempty"`
}
type messageContent struct {
Type string `json:"type,omitempty"`
Text string `json:"text"`
ImageUrl *imageUrl `json:"image_url,omitempty"`
}
type imageUrl struct {
Url string `json:"url,omitempty"`
Detail string `json:"detail,omitempty"`
}
func (m *chatMessage) IsEmpty() bool {
if m.Content != "" {
if m.IsStringContent() && m.Content != "" {
return false
}
anyList, ok := m.Content.([]any)
if ok && len(anyList) > 0 {
return false
}
if len(m.ToolCalls) != 0 {
@@ -102,6 +121,76 @@ func (m *chatMessage) IsEmpty() bool {
return true
}
func (m *chatMessage) IsStringContent() bool {
_, ok := m.Content.(string)
return ok
}
func (m *chatMessage) StringContent() string {
content, ok := m.Content.(string)
if ok {
return content
}
contentList, ok := m.Content.([]any)
if ok {
var contentStr string
for _, contentItem := range contentList {
contentMap, ok := contentItem.(map[string]any)
if !ok {
continue
}
if contentMap["type"] == contentTypeText {
if subStr, ok := contentMap[contentTypeText].(string); ok {
contentStr += subStr + "\n"
}
}
}
return contentStr
}
return ""
}
func (m *chatMessage) ParseContent() []messageContent {
var contentList []messageContent
content, ok := m.Content.(string)
if ok {
contentList = append(contentList, messageContent{
Type: contentTypeText,
Text: content,
})
return contentList
}
anyList, ok := m.Content.([]any)
if ok {
for _, contentItem := range anyList {
contentMap, ok := contentItem.(map[string]any)
if !ok {
continue
}
switch contentMap["type"] {
case contentTypeText:
if subStr, ok := contentMap[contentTypeText].(string); ok {
contentList = append(contentList, messageContent{
Type: contentTypeText,
Text: subStr,
})
}
case contentTypeImageUrl:
if subObj, ok := contentMap[contentTypeImageUrl].(map[string]any); ok {
contentList = append(contentList, messageContent{
Type: contentTypeImageUrl,
ImageUrl: &imageUrl{
Url: subObj["url"].(string),
},
})
}
}
}
return contentList
}
return nil
}
type toolCall struct {
Index int `json:"index"`
Id string `json:"id"`

View File

@@ -89,6 +89,10 @@ func (m *openaiProvider) OnRequestBody(ctx wrapper.HttpContext, apiName ApiName,
if err := decodeChatCompletionRequest(body, request); err != nil {
return types.ActionContinue, err
}
if m.config.responseJsonSchema != nil {
log.Debugf("[ai-proxy] set response format to %s", m.config.responseJsonSchema)
request.ResponseFormat = m.config.responseJsonSchema
}
if request.Stream {
// For stream requests, we need to include usage in the response.
if request.StreamOptions == nil {

View File

@@ -19,6 +19,7 @@ const (
providerTypeMoonshot = "moonshot"
providerTypeAzure = "azure"
providerTypeAi360 = "ai360"
providerTypeQwen = "qwen"
providerTypeOpenAI = "openai"
providerTypeGroq = "groq"
@@ -35,6 +36,7 @@ const (
providerTypeCloudflare = "cloudflare"
providerTypeSpark = "spark"
providerTypeGemini = "gemini"
providerTypeDeepl = "deepl"
protocolOpenAI = "openai"
protocolOriginal = "original"
@@ -72,6 +74,7 @@ var (
providerInitializers = map[string]providerInitializer{
providerTypeMoonshot: &moonshotProviderInitializer{},
providerTypeAzure: &azureProviderInitializer{},
providerTypeAi360: &ai360ProviderInitializer{},
providerTypeQwen: &qwenProviderInitializer{},
providerTypeOpenAI: &openaiProviderInitializer{},
providerTypeGroq: &groqProviderInitializer{},
@@ -88,6 +91,7 @@ var (
providerTypeCloudflare: &cloudflareProviderInitializer{},
providerTypeSpark: &sparkProviderInitializer{},
providerTypeGemini: &geminiProviderInitializer{},
providerTypeDeepl: &deeplProviderInitializer{},
}
)
@@ -140,6 +144,9 @@ type ProviderConfig struct {
// @Title zh-CN 启用通义千问搜索服务
// @Description zh-CN 仅适用于通义千问服务,表示是否启用通义千问的互联网搜索功能。
qwenEnableSearch bool `required:"false" yaml:"qwenEnableSearch" json:"qwenEnableSearch"`
// @Title zh-CN 开启通义千问兼容模式
// @Description zh-CN 启用通义千问兼容模式后,将调用千问的兼容模式接口,同时对请求/响应不做修改。
qwenEnableCompatible bool `required:"false" yaml:"qwenEnableCompatible" json:"qwenEnableCompatible"`
// @Title zh-CN Ollama Server IP/Domain
// @Description zh-CN 仅适用于 Ollama 服务。Ollama 服务器的主机地址。
ollamaServerHost string `required:"false" yaml:"ollamaServerHost" json:"ollamaServerHost"`
@@ -173,6 +180,15 @@ type ProviderConfig struct {
// @Title zh-CN Gemini AI内容过滤和安全级别设定
// @Description zh-CN 仅适用于 Gemini AI 服务。参考https://ai.google.dev/gemini-api/docs/safety-settings
geminiSafetySetting map[string]string `required:"false" yaml:"geminiSafetySetting" json:"geminiSafetySetting"`
// @Title zh-CN 翻译服务需指定的目标语种
// @Description zh-CN 翻译结果的语种目前仅适用于DeepL服务。
targetLang string `required:"false" yaml:"targetLang" json:"targetLang"`
// @Title zh-CN 指定服务返回的响应需满足的JSON Schema
// @Description zh-CN 目前仅适用于OpenAI部分模型服务。参考https://platform.openai.com/docs/guides/structured-outputs
responseJsonSchema map[string]interface{} `required:"false" yaml:"responseJsonSchema" json:"responseJsonSchema"`
// @Title zh-CN 自定义大模型参数配置
// @Description zh-CN 用于填充或者覆盖大模型调用时的参数
customSettings []CustomSetting
}
func (c *ProviderConfig) FromJson(json gjson.Result) {
@@ -193,6 +209,7 @@ func (c *ProviderConfig) FromJson(json gjson.Result) {
c.qwenFileIds = append(c.qwenFileIds, fileId.String())
}
c.qwenEnableSearch = json.Get("qwenEnableSearch").Bool()
c.qwenEnableCompatible = json.Get("qwenEnableCompatible").Bool()
c.ollamaServerHost = json.Get("ollamaServerHost").String()
c.ollamaServerPort = uint32(json.Get("ollamaServerPort").Uint())
c.modelMapping = make(map[string]string)
@@ -219,6 +236,32 @@ func (c *ProviderConfig) FromJson(json gjson.Result) {
c.geminiSafetySetting[k] = v.String()
}
}
c.targetLang = json.Get("targetLang").String()
if schemaValue, ok := json.Get("responseJsonSchema").Value().(map[string]interface{}); ok {
c.responseJsonSchema = schemaValue
} else {
c.responseJsonSchema = nil
}
c.customSettings = make([]CustomSetting, 0)
customSettingsJson := json.Get("customSettings")
if customSettingsJson.Exists() {
protocol := protocolOpenAI
if c.protocol == protocolOriginal {
// use provider name to represent original protocol name
protocol = c.typ
}
for _, settingJson := range customSettingsJson.Array() {
setting := CustomSetting{}
setting.FromJson(settingJson)
// use protocol info to rewrite setting
setting.AdjustWithProtocol(protocol)
if setting.Validate() {
c.customSettings = append(c.customSettings, setting)
}
}
}
}
func (c *ProviderConfig) Validate() error {
@@ -304,3 +347,7 @@ func doGetMappedModel(model string, modelMapping map[string]string, log wrapper.
return ""
}
func (c ProviderConfig) ReplaceByCustomSettings(body []byte) ([]byte, error) {
return ReplaceByCustomSettings(body, c.customSettings)
}

View File

@@ -13,6 +13,8 @@ import (
"github.com/alibaba/higress/plugins/wasm-go/pkg/wrapper"
"github.com/higress-group/proxy-wasm-go-sdk/proxywasm"
"github.com/higress-group/proxy-wasm-go-sdk/proxywasm/types"
"github.com/tidwall/gjson"
"github.com/tidwall/sjson"
)
// qwenProvider is the provider for Qwen service.
@@ -20,16 +22,19 @@ import (
const (
qwenResultFormatMessage = "message"
qwenDomain = "dashscope.aliyuncs.com"
qwenChatCompletionPath = "/api/v1/services/aigc/text-generation/generation"
qwenTextEmbeddingPath = "/api/v1/services/embeddings/text-embedding/text-embedding"
qwenDomain = "dashscope.aliyuncs.com"
qwenChatCompletionPath = "/api/v1/services/aigc/text-generation/generation"
qwenTextEmbeddingPath = "/api/v1/services/embeddings/text-embedding/text-embedding"
qwenCompatiblePath = "/compatible-mode/v1/chat/completions"
qwenMultimodalGenerationPath = "/api/v1/services/aigc/multimodal-generation/generation"
qwenTopPMin = 0.000001
qwenTopPMax = 0.999999
qwenDummySystemMessageContent = "You are a helpful assistant."
qwenLongModelName = "qwen-long"
qwenLongModelName = "qwen-long"
qwenVlModelPrefixName = "qwen-vl"
)
type qwenProviderInitializer struct {
@@ -63,7 +68,9 @@ func (m *qwenProvider) GetProviderType() string {
}
func (m *qwenProvider) OnRequestHeaders(ctx wrapper.HttpContext, apiName ApiName, log wrapper.Log) (types.Action, error) {
if apiName == ApiNameChatCompletion {
if m.config.qwenEnableCompatible {
_ = util.OverwriteRequestPath(qwenCompatiblePath)
} else if apiName == ApiNameChatCompletion {
_ = util.OverwriteRequestPath(qwenChatCompletionPath)
} else if apiName == ApiNameEmbeddings {
_ = util.OverwriteRequestPath(qwenTextEmbeddingPath)
@@ -85,6 +92,23 @@ func (m *qwenProvider) OnRequestHeaders(ctx wrapper.HttpContext, apiName ApiName
}
func (m *qwenProvider) OnRequestBody(ctx wrapper.HttpContext, apiName ApiName, body []byte, log wrapper.Log) (types.Action, error) {
if m.config.qwenEnableCompatible {
if gjson.GetBytes(body, "model").Exists() {
rawModel := gjson.GetBytes(body, "model").String()
mappedModel := getMappedModel(rawModel, m.config.modelMapping, log)
newBody, err := sjson.SetBytes(body, "model", mappedModel)
if err != nil {
log.Errorf("Replace model error: %v", err)
return types.ActionContinue, err
}
err = proxywasm.ReplaceHttpRequestBody(newBody)
if err != nil {
log.Errorf("Replace request body error: %v", err)
return types.ActionContinue, err
}
}
return types.ActionContinue, nil
}
if apiName == ApiNameChatCompletion {
return m.onChatCompletionRequestBody(ctx, body, log)
}
@@ -141,6 +165,10 @@ func (m *qwenProvider) onChatCompletionRequestBody(ctx wrapper.HttpContext, body
}
request.Model = mappedModel
ctx.SetContext(ctxKeyFinalRequestModel, request.Model)
// Use the qwen multimodal model generation API
if strings.HasPrefix(request.Model, qwenVlModelPrefixName) {
_ = util.OverwriteRequestPath(qwenMultimodalGenerationPath)
}
streaming := request.Stream
if streaming {
@@ -220,7 +248,7 @@ func (m *qwenProvider) OnResponseHeaders(ctx wrapper.HttpContext, apiName ApiNam
}
func (m *qwenProvider) OnStreamingResponseBody(ctx wrapper.HttpContext, name ApiName, chunk []byte, isLastChunk bool, log wrapper.Log) ([]byte, error) {
if name != ApiNameChatCompletion {
if m.config.qwenEnableCompatible || name != ApiNameChatCompletion {
return chunk, nil
}
@@ -305,6 +333,9 @@ func (m *qwenProvider) OnStreamingResponseBody(ctx wrapper.HttpContext, name Api
}
func (m *qwenProvider) OnResponseBody(ctx wrapper.HttpContext, apiName ApiName, body []byte, log wrapper.Log) (types.Action, error) {
if m.config.qwenEnableCompatible {
return types.ActionContinue, nil
}
if apiName == ApiNameChatCompletion {
return m.onChatCompletionResponseBody(ctx, body, log)
}
@@ -425,8 +456,29 @@ func (m *qwenProvider) buildChatCompletionStreamingResponse(ctx wrapper.HttpCont
if pushedMessage, ok := ctx.GetContext(ctxKeyPushedMessage).(qwenMessage); ok {
if message.Content == "" {
message.Content = pushedMessage.Content
} else if message.IsStringContent() {
deltaContentMessage.Content = util.StripPrefix(deltaContentMessage.StringContent(), pushedMessage.StringContent())
} else if strings.HasPrefix(baseMessage.Model, qwenVlModelPrefixName) {
// Use the Qwen multimodal model generation API
deltaContentList, ok := deltaContentMessage.Content.([]qwenVlMessageContent)
if !ok {
log.Warnf("unexpected deltaContentMessage content type: %T", deltaContentMessage.Content)
} else {
pushedContentList, ok := pushedMessage.Content.([]qwenVlMessageContent)
if !ok {
log.Warnf("unexpected pushedMessage content type: %T", pushedMessage.Content)
} else {
for i, content := range deltaContentList {
if i >= len(pushedContentList) {
break
}
pushedText := pushedContentList[i].Text
content.Text = util.StripPrefix(content.Text, pushedText)
deltaContentList[i] = content
}
}
}
}
deltaContentMessage.Content = util.StripPrefix(deltaContentMessage.Content, pushedMessage.Content)
if len(deltaToolCallsMessage.ToolCalls) > 0 && pushedMessage.ToolCalls != nil {
for i, tc := range deltaToolCallsMessage.ToolCalls {
if i >= len(pushedMessage.ToolCalls) {
@@ -532,7 +584,7 @@ func (m *qwenProvider) insertContextMessage(request *qwenTextGenRequest, content
if builder.Len() != 0 {
builder.WriteString("\n")
}
builder.WriteString(message.Content)
builder.WriteString(message.StringContent())
}
request.Input.Messages = append([]qwenMessage{{Role: roleSystem, Content: builder.String()}, fileMessage}, request.Input.Messages[firstNonSystemMessageIndex:]...)
return 1
@@ -637,10 +689,15 @@ type qwenUsage struct {
type qwenMessage struct {
Name string `json:"name,omitempty"`
Role string `json:"role"`
Content string `json:"content"`
Content any `json:"content"`
ToolCalls []toolCall `json:"tool_calls,omitempty"`
}
type qwenVlMessageContent struct {
Image string `json:"image,omitempty"`
Text string `json:"text,omitempty"`
}
type qwenTextEmbeddingRequest struct {
Model string `json:"model"`
Input qwenTextEmbeddingInput `json:"input"`
@@ -680,11 +737,58 @@ func qwenMessageToChatMessage(qwenMessage qwenMessage) chatMessage {
}
}
func (m *qwenMessage) IsStringContent() bool {
_, ok := m.Content.(string)
return ok
}
func (m *qwenMessage) StringContent() string {
content, ok := m.Content.(string)
if ok {
return content
}
contentList, ok := m.Content.([]any)
if ok {
var contentStr string
for _, contentItem := range contentList {
contentMap, ok := contentItem.(map[string]any)
if !ok {
continue
}
if text, ok := contentMap["text"].(string); ok {
contentStr += text
}
}
return contentStr
}
return ""
}
func chatMessage2QwenMessage(chatMessage chatMessage) qwenMessage {
return qwenMessage{
Name: chatMessage.Name,
Role: chatMessage.Role,
Content: chatMessage.Content,
ToolCalls: chatMessage.ToolCalls,
if chatMessage.IsStringContent() {
return qwenMessage{
Name: chatMessage.Name,
Role: chatMessage.Role,
Content: chatMessage.StringContent(),
ToolCalls: chatMessage.ToolCalls,
}
} else {
var contents []qwenVlMessageContent
openaiContent := chatMessage.ParseContent()
for _, part := range openaiContent {
var content qwenVlMessageContent
if part.Type == contentTypeText {
content.Text = part.Text
} else if part.Type == contentTypeImageUrl {
content.Image = part.ImageUrl.Url
}
contents = append(contents, content)
}
return qwenMessage{
Name: chatMessage.Name,
Role: chatMessage.Role,
Content: contents,
ToolCalls: chatMessage.ToolCalls,
}
}
}

View File

@@ -0,0 +1,58 @@
# 功能说明
`ai-qutoa` 插件实现给特定 consumer 根据分配固定的 quota 进行 quota 策略限流,同时支持 quota 管理能力,包括查询 quota 、刷新 quota、增减 quota。
`ai-quota` 插件需要配合 认证插件比如 `key-auth``jwt-auth` 等插件获取认证身份的 consumer 名称,同时需要配合 `ai-statatistics` 插件获取 AI Token 统计信息。
# 配置说明
| 名称 | 数据类型 | 填写要求 | 默认值 | 描述 |
|--------------------|-----------------|--------------------------------------| ---- |--------------------------------------------|
| `redis_key_prefix` | string | 选填 | chat_quota: | qutoa redis key 前缀 |
| `admin_consumer` | string | 必填 | | 管理 quota 管理身份的 consumer 名称 |
| `admin_path` | string | 选填 | /quota | 管理 quota 请求 path 前缀 |
| `redis` | object | 是 | | redis相关配置 |
`redis`中每一项的配置字段说明
| 配置项 | 类型 | 必填 | 默认值 | 说明 |
| ------------ | ------ | ---- | ---------------------------------------------------------- | --------------------------- |
| service_name | string | 必填 | - | redis 服务名称,带服务类型的完整 FQDN 名称,例如 my-redis.dns、redis.my-ns.svc.cluster.local |
| service_port | int | 否 | 服务类型为固定地址static service默认值为80其他为6379 | 输入redis服务的服务端口 |
| username | string | 否 | - | redis用户名 |
| password | string | 否 | - | redis密码 |
| timeout | int | 否 | 1000 | redis连接超时时间单位毫秒 |
# 配置示例
## 识别请求参数 apikey进行区别限流
```yaml
redis_key_prefix: "chat_quota:"
admin_consumer: consumer3
admin_path: /quota
redis:
service_name: redis-service.default.svc.cluster.local
service_port: 6379
timeout: 2000
```
## 刷新 quota
如果当前请求 url 的后缀符合 admin_path例如插件在 example.com/v1/chat/completions 这个路由上生效,那么更新 quota 可以通过
curl https://example.com/v1/chat/completions/quota/refresh -H "Authorization: Bearer credential3" -d "consumer=consumer1&quota=10000"
Redis 中 key 为 chat_quota:consumer1 的值就会被刷新为 10000
## 查询 quota
查询特定用户的 quota 可以通过 curl https://example.com/v1/chat/completions/quota?consumer=consumer1 -H "Authorization: Bearer credential3"
将返回: {"quota": 10000, "consumer": "consumer1"}
## 增减 quota
增减特定用户的 quota 可以通过 curl https://example.com/v1/chat/completions/quota/delta -d "consumer=consumer1&value=100" -H "Authorization: Bearer credential3"
这样 Redis 中 Key 为 chat_quota:consumer1 的值就会增加100可以支持负数则减去对应值。

View File

@@ -0,0 +1,20 @@
module github.com/alibaba/higress/plugins/wasm-go/extensions/ai-quota
go 1.19
//replace github.com/alibaba/higress/plugins/wasm-go => ../..
require (
github.com/alibaba/higress/plugins/wasm-go v1.4.3-0.20240808022948-34f5722d93de
github.com/higress-group/proxy-wasm-go-sdk v0.0.0-20240711023527-ba358c48772f
github.com/tidwall/gjson v1.17.3
github.com/tidwall/resp v0.1.1
)
require (
github.com/google/uuid v1.3.0 // indirect
github.com/higress-group/nottinygc v0.0.0-20231101025119-e93c4c2f8520 // indirect
github.com/magefile/mage v1.14.0 // indirect
github.com/tidwall/match v1.1.1 // indirect
github.com/tidwall/pretty v1.2.0 // indirect
)

View File

@@ -0,0 +1,22 @@
github.com/alibaba/higress/plugins/wasm-go v1.4.3-0.20240808022948-34f5722d93de h1:lDLqj7Hw41ox8VdsP7oCTPhjPa3+QJUCKApcLh2a45Y=
github.com/alibaba/higress/plugins/wasm-go v1.4.3-0.20240808022948-34f5722d93de/go.mod h1:359don/ahMxpfeLMzr29Cjwcu8IywTTDUzWlBPRNLHw=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/google/uuid v1.3.0 h1:t6JiXgmwXMjEs8VusXIJk2BXHsn+wx8BZdTaoZ5fu7I=
github.com/google/uuid v1.3.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/higress-group/nottinygc v0.0.0-20231101025119-e93c4c2f8520 h1:IHDghbGQ2DTIXHBHxWfqCYQW1fKjyJ/I7W1pMyUDeEA=
github.com/higress-group/nottinygc v0.0.0-20231101025119-e93c4c2f8520/go.mod h1:Nz8ORLaFiLWotg6GeKlJMhv8cci8mM43uEnLA5t8iew=
github.com/higress-group/proxy-wasm-go-sdk v0.0.0-20240711023527-ba358c48772f h1:ZIiIBRvIw62gA5MJhuwp1+2wWbqL9IGElQ499rUsYYg=
github.com/higress-group/proxy-wasm-go-sdk v0.0.0-20240711023527-ba358c48772f/go.mod h1:hNFjhrLUIq+kJ9bOcs8QtiplSQ61GZXtd2xHKx4BYRo=
github.com/magefile/mage v1.14.0 h1:6QDX3g6z1YvJ4olPhT1wksUcSa/V0a1B+pJb73fBjyo=
github.com/magefile/mage v1.14.0/go.mod h1:z5UZb/iS3GoOSn0JgWuiw7dxlurVYTu+/jHXqQg881A=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk=
github.com/tidwall/gjson v1.17.3 h1:bwWLZU7icoKRG+C+0PNwIKC6FCJO/Q3p2pZvuP0jN94=
github.com/tidwall/gjson v1.17.3/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk=
github.com/tidwall/match v1.1.1 h1:+Ho715JplO36QYgwN9PGYNhgZvoUSc9X2c80KVTi+GA=
github.com/tidwall/match v1.1.1/go.mod h1:eRSPERbgtNPcGhD8UCthc6PmLEQXEWd3PRB5JTxsfmM=
github.com/tidwall/pretty v1.2.0 h1:RWIZEg2iJ8/g6fDDYzMpobmaoGh5OLl4AXtGUGPcqCs=
github.com/tidwall/pretty v1.2.0/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU=
github.com/tidwall/resp v0.1.1 h1:Ly20wkhqKTmDUPlyM1S7pWo5kk0tDu8OoC/vFArXmwE=
github.com/tidwall/resp v0.1.1/go.mod h1:3/FrruOBAxPTPtundW0VXgmsQ4ZBA0Aw714lVYgwFa0=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=

View File

@@ -0,0 +1,399 @@
package main
import (
"encoding/json"
"errors"
"fmt"
"net/http"
"net/url"
"strconv"
"strings"
"github.com/alibaba/higress/plugins/wasm-go/extensions/ai-quota/util"
"github.com/alibaba/higress/plugins/wasm-go/pkg/wrapper"
"github.com/higress-group/proxy-wasm-go-sdk/proxywasm"
"github.com/higress-group/proxy-wasm-go-sdk/proxywasm/types"
"github.com/tidwall/gjson"
"github.com/tidwall/resp"
)
const (
pluginName = "ai-quota"
)
type ChatMode string
const (
ChatModeCompletion ChatMode = "completion"
ChatModeAdmin ChatMode = "admin"
ChatModeNone ChatMode = "none"
)
type AdminMode string
const (
AdminModeRefresh AdminMode = "refresh"
AdminModeQuery AdminMode = "query"
AdminModeDelta AdminMode = "delta"
AdminModeNone AdminMode = "none"
)
func main() {
wrapper.SetCtx(
pluginName,
wrapper.ParseConfigBy(parseConfig),
wrapper.ProcessRequestHeadersBy(onHttpRequestHeaders),
wrapper.ProcessRequestBodyBy(onHttpRequestBody),
wrapper.ProcessStreamingResponseBodyBy(onHttpStreamingResponseBody),
)
}
type QuotaConfig struct {
redisInfo RedisInfo `yaml:"redis"`
RedisKeyPrefix string `yaml:"redis_key_prefix"`
AdminConsumer string `yaml:"admin_consumer"`
AdminPath string `yaml:"admin_path"`
credential2Name map[string]string `yaml:"-"`
redisClient wrapper.RedisClient
}
type Consumer struct {
Name string `yaml:"name"`
Credential string `yaml:"credential"`
}
type RedisInfo struct {
ServiceName string `required:"true" yaml:"service_name" json:"service_name"`
ServicePort int `required:"false" yaml:"service_port" json:"service_port"`
Username string `required:"false" yaml:"username" json:"username"`
Password string `required:"false" yaml:"password" json:"password"`
Timeout int `required:"false" yaml:"timeout" json:"timeout"`
}
func parseConfig(json gjson.Result, config *QuotaConfig, log wrapper.Log) error {
log.Debugf("parse config()")
// admin
config.AdminPath = json.Get("admin_path").String()
config.AdminConsumer = json.Get("admin_consumer").String()
if config.AdminPath == "" {
config.AdminPath = "/quota"
}
if config.AdminConsumer == "" {
return errors.New("missing admin_consumer in config")
}
// Redis
config.RedisKeyPrefix = json.Get("redis_key_prefix").String()
if config.RedisKeyPrefix == "" {
config.RedisKeyPrefix = "chat_quota:"
}
redisConfig := json.Get("redis")
if !redisConfig.Exists() {
return errors.New("missing redis in config")
}
serviceName := redisConfig.Get("service_name").String()
if serviceName == "" {
return errors.New("redis service name must not be empty")
}
servicePort := int(redisConfig.Get("service_port").Int())
if servicePort == 0 {
if strings.HasSuffix(serviceName, ".static") {
// use default logic port which is 80 for static service
servicePort = 80
} else {
servicePort = 6379
}
}
username := redisConfig.Get("username").String()
password := redisConfig.Get("password").String()
timeout := int(redisConfig.Get("timeout").Int())
if timeout == 0 {
timeout = 1000
}
config.redisInfo.ServiceName = serviceName
config.redisInfo.ServicePort = servicePort
config.redisInfo.Username = username
config.redisInfo.Password = password
config.redisInfo.Timeout = timeout
config.redisClient = wrapper.NewRedisClusterClient(wrapper.FQDNCluster{
FQDN: serviceName,
Port: int64(servicePort),
})
return config.redisClient.Init(username, password, int64(timeout))
}
func onHttpRequestHeaders(context wrapper.HttpContext, config QuotaConfig, log wrapper.Log) types.Action {
log.Debugf("onHttpRequestHeaders()")
// get tokens
consumer, err := proxywasm.GetHttpRequestHeader("x-mse-consumer")
if err != nil {
return deniedNoKeyAuthData()
}
if consumer == "" {
return deniedUnauthorizedConsumer()
}
rawPath := context.Path()
path, _ := url.Parse(rawPath)
chatMode, adminMode := getOperationMode(path.Path, config.AdminPath, log)
context.SetContext("chatMode", chatMode)
context.SetContext("adminMode", adminMode)
context.SetContext("consumer", consumer)
log.Debugf("chatMode:%s, adminMode:%s, consumer:%s", chatMode, adminMode, consumer)
if chatMode == ChatModeNone {
return types.ActionContinue
}
if chatMode == ChatModeAdmin {
// query quota
if adminMode == AdminModeQuery {
return queryQuota(context, config, consumer, path, log)
}
if adminMode == AdminModeRefresh || adminMode == AdminModeDelta {
context.BufferRequestBody()
return types.HeaderStopIteration
}
return types.ActionContinue
}
// there is no need to read request body when it is on chat completion mode
context.DontReadRequestBody()
// check quota here
config.redisClient.Get(config.RedisKeyPrefix+consumer, func(response resp.Value) {
isDenied := false
if err := response.Error(); err != nil {
isDenied = true
}
if response.IsNull() {
isDenied = true
}
if response.Integer() <= 0 {
isDenied = true
}
log.Debugf("get consumer:%s quota:%d isDenied:%t", consumer, response.Integer(), isDenied)
if isDenied {
util.SendResponse(http.StatusForbidden, "ai-quota.noquota", "text/plain", "Request denied by ai quota check, No quota left")
return
}
proxywasm.ResumeHttpRequest()
})
return types.HeaderStopAllIterationAndWatermark
}
func onHttpRequestBody(ctx wrapper.HttpContext, config QuotaConfig, body []byte, log wrapper.Log) types.Action {
log.Debugf("onHttpRequestBody()")
chatMode, ok := ctx.GetContext("chatMode").(ChatMode)
if !ok {
return types.ActionContinue
}
if chatMode == ChatModeNone || chatMode == ChatModeCompletion {
return types.ActionContinue
}
adminMode, ok := ctx.GetContext("adminMode").(AdminMode)
if !ok {
return types.ActionContinue
}
adminConsumer, ok := ctx.GetContext("consumer").(string)
if !ok {
return types.ActionContinue
}
if adminMode == AdminModeRefresh {
return refreshQuota(ctx, config, adminConsumer, string(body), log)
}
if adminMode == AdminModeDelta {
return deltaQuota(ctx, config, adminConsumer, string(body), log)
}
return types.ActionContinue
}
func onHttpStreamingResponseBody(ctx wrapper.HttpContext, config QuotaConfig, data []byte, endOfStream bool, log wrapper.Log) []byte {
chatMode, ok := ctx.GetContext("chatMode").(ChatMode)
if !ok {
return data
}
if chatMode == ChatModeNone || chatMode == ChatModeAdmin {
return data
}
// chat completion mode
if !endOfStream {
return data
}
inputTokenStr, err := proxywasm.GetProperty([]string{"filter_state", "wasm.input_token"})
if err != nil {
return data
}
outputTokenStr, err := proxywasm.GetProperty([]string{"filter_state", "wasm.output_token"})
if err != nil {
return data
}
inputToken, err := strconv.Atoi(string(inputTokenStr))
if err != nil {
return data
}
outputToken, err := strconv.Atoi(string(outputTokenStr))
if err != nil {
return data
}
consumer, ok := ctx.GetContext("consumer").(string)
if ok {
totalToken := int(inputToken + outputToken)
log.Debugf("update consumer:%s, totalToken:%d", consumer, totalToken)
config.redisClient.DecrBy(config.RedisKeyPrefix+consumer, totalToken, nil)
}
return data
}
func deniedNoKeyAuthData() types.Action {
util.SendResponse(http.StatusUnauthorized, "ai-quota.no_key", "text/plain", "Request denied by ai quota check. No Key Authentication information found.")
return types.ActionContinue
}
func deniedUnauthorizedConsumer() types.Action {
util.SendResponse(http.StatusForbidden, "ai-quota.unauthorized", "text/plain", "Request denied by ai quota check. Unauthorized consumer.")
return types.ActionContinue
}
func getOperationMode(path string, adminPath string, log wrapper.Log) (ChatMode, AdminMode) {
fullAdminPath := "/v1/chat/completions" + adminPath
if strings.HasSuffix(path, fullAdminPath+"/refresh") {
return ChatModeAdmin, AdminModeRefresh
}
if strings.HasSuffix(path, fullAdminPath+"/delta") {
return ChatModeAdmin, AdminModeDelta
}
if strings.HasSuffix(path, fullAdminPath) {
return ChatModeAdmin, AdminModeQuery
}
if strings.HasSuffix(path, "/v1/chat/completions") {
return ChatModeCompletion, AdminModeNone
}
return ChatModeNone, AdminModeNone
}
func refreshQuota(ctx wrapper.HttpContext, config QuotaConfig, adminConsumer string, body string, log wrapper.Log) types.Action {
// check consumer
if adminConsumer != config.AdminConsumer {
util.SendResponse(http.StatusForbidden, "ai-quota.unauthorized", "text/plain", "Request denied by ai quota check. Unauthorized admin consumer.")
return types.ActionContinue
}
queryValues, _ := url.ParseQuery(body)
values := make(map[string]string, len(queryValues))
for k, v := range queryValues {
values[k] = v[0]
}
queryConsumer := values["consumer"]
quota, err := strconv.Atoi(values["quota"])
if queryConsumer == "" || err != nil {
util.SendResponse(http.StatusForbidden, "ai-quota.unauthorized", "text/plain", "Request denied by ai quota check. consumer can't be empty and quota must be integer.")
return types.ActionContinue
}
err2 := config.redisClient.Set(config.RedisKeyPrefix+queryConsumer, quota, func(response resp.Value) {
log.Debugf("Redis set key = %s quota = %d", config.RedisKeyPrefix+queryConsumer, quota)
if err := response.Error(); err != nil {
util.SendResponse(http.StatusServiceUnavailable, "ai-quota.error", "text/plain", fmt.Sprintf("redis error:%v", err))
return
}
util.SendResponse(http.StatusOK, "ai-quota.refreshquota", "text/plain", "refresh quota successful")
})
if err2 != nil {
util.SendResponse(http.StatusServiceUnavailable, "ai-quota.error", "text/plain", fmt.Sprintf("redis error:%v", err))
return types.ActionContinue
}
return types.ActionPause
}
func queryQuota(ctx wrapper.HttpContext, config QuotaConfig, adminConsumer string, url *url.URL, log wrapper.Log) types.Action {
// check consumer
if adminConsumer != config.AdminConsumer {
util.SendResponse(http.StatusForbidden, "ai-quota.unauthorized", "text/plain", "Request denied by ai quota check. Unauthorized admin consumer.")
return types.ActionContinue
}
// check url
queryValues := url.Query()
values := make(map[string]string, len(queryValues))
for k, v := range queryValues {
values[k] = v[0]
}
if values["consumer"] == "" {
util.SendResponse(http.StatusForbidden, "ai-quota.unauthorized", "text/plain", "Request denied by ai quota check. consumer can't be empty.")
return types.ActionContinue
}
queryConsumer := values["consumer"]
err := config.redisClient.Get(config.RedisKeyPrefix+queryConsumer, func(response resp.Value) {
quota := 0
if err := response.Error(); err != nil {
util.SendResponse(http.StatusServiceUnavailable, "ai-quota.error", "text/plain", fmt.Sprintf("redis error:%v", err))
return
} else if response.IsNull() {
quota = 0
} else {
quota = response.Integer()
}
result := struct {
Consumer string `json:"consumer"`
Quota int `json:"quota"`
}{
Consumer: queryConsumer,
Quota: quota,
}
body, _ := json.Marshal(result)
util.SendResponse(http.StatusOK, "ai-quota.queryquota", "application/json", string(body))
})
if err != nil {
util.SendResponse(http.StatusServiceUnavailable, "ai-quota.error", "text/plain", fmt.Sprintf("redis error:%v", err))
return types.ActionContinue
}
return types.ActionPause
}
func deltaQuota(ctx wrapper.HttpContext, config QuotaConfig, adminConsumer string, body string, log wrapper.Log) types.Action {
// check consumer
if adminConsumer != config.AdminConsumer {
util.SendResponse(http.StatusForbidden, "ai-quota.unauthorized", "text/plain", "Request denied by ai quota check. Unauthorized admin consumer.")
return types.ActionContinue
}
queryValues, _ := url.ParseQuery(body)
values := make(map[string]string, len(queryValues))
for k, v := range queryValues {
values[k] = v[0]
}
queryConsumer := values["consumer"]
value, err := strconv.Atoi(values["value"])
if queryConsumer == "" || err != nil {
util.SendResponse(http.StatusForbidden, "ai-quota.unauthorized", "text/plain", "Request denied by ai quota check. consumer can't be empty and value must be integer.")
return types.ActionContinue
}
if value >= 0 {
err := config.redisClient.IncrBy(config.RedisKeyPrefix+queryConsumer, value, func(response resp.Value) {
log.Debugf("Redis Incr key = %s value = %d", config.RedisKeyPrefix+queryConsumer, value)
if err := response.Error(); err != nil {
util.SendResponse(http.StatusServiceUnavailable, "ai-quota.error", "text/plain", fmt.Sprintf("redis error:%v", err))
return
}
util.SendResponse(http.StatusOK, "ai-quota.deltaquota", "text/plain", "delta quota successful")
})
if err != nil {
util.SendResponse(http.StatusServiceUnavailable, "ai-quota.error", "text/plain", fmt.Sprintf("redis error:%v", err))
return types.ActionContinue
}
} else {
err := config.redisClient.DecrBy(config.RedisKeyPrefix+queryConsumer, 0-value, func(response resp.Value) {
log.Debugf("Redis Decr key = %s value = %d", config.RedisKeyPrefix+queryConsumer, 0-value)
if err := response.Error(); err != nil {
util.SendResponse(http.StatusServiceUnavailable, "ai-quota.error", "text/plain", fmt.Sprintf("redis error:%v", err))
return
}
util.SendResponse(http.StatusOK, "ai-quota.deltaquota", "text/plain", "delta quota successful")
})
if err != nil {
util.SendResponse(http.StatusServiceUnavailable, "ai-quota.error", "text/plain", fmt.Sprintf("redis error:%v", err))
return types.ActionContinue
}
}
return types.ActionPause
}

View File

@@ -0,0 +1,61 @@
apiVersion: extensions.higress.io/v1alpha1
kind: WasmPlugin
metadata:
name: ai-quota
namespace: higress-system
spec:
defaultConfig: {}
defaultConfigDisable: true
matchRules:
- config:
redis_key_prefix: "chat_quota:"
admin_consumer: consumer3
admin_path: /quota
redis:
service_name: redis-service.default.svc.cluster.local
service_port: 6379
timeout: 2000
configDisable: false
ingress:
- qwen
phase: UNSPECIFIED_PHASE
priority: 280
url: oci://registry.cn-hangzhou.aliyuncs.com/2456868764/ai-quota:1.0.8
---
apiVersion: extensions.higress.io/v1alpha1
kind: WasmPlugin
metadata:
name: ai-statistics
namespace: higress-system
spec:
defaultConfig:
enable: true
defaultConfigDisable: false
phase: UNSPECIFIED_PHASE
priority: 250
url: oci://higress-registry.cn-hangzhou.cr.aliyuncs.com/plugins/ai-statistics:1.0.0
---
apiVersion: extensions.higress.io/v1alpha1
kind: WasmPlugin
metadata:
name: wasm-keyauth
namespace: higress-system
spec:
defaultConfig:
consumers:
- credential: "Bearer credential1"
name: consumer1
- credential: "Bearer credential2"
name: consumer2
- credential: "Bearer credential3"
name: consumer3
global_auth: true
keys:
- authorization
in_header: true
defaultConfigDisable: false
priority: 300
url: oci://higress-registry.cn-hangzhou.cr.aliyuncs.com/plugins/key-auth:1.0.0
imagePullPolicy: Always

View File

@@ -0,0 +1,22 @@
package util
import "github.com/higress-group/proxy-wasm-go-sdk/proxywasm"
const (
HeaderContentType = "Content-Type"
MimeTypeTextPlain = "text/plain"
MimeTypeApplicationJson = "application/json"
)
func SendResponse(statusCode uint32, statusCodeDetails string, contentType, body string) error {
return proxywasm.SendHttpResponseWithDetail(statusCode, statusCodeDetails, CreateHeaders(HeaderContentType, contentType), []byte(body), -1)
}
func CreateHeaders(kvs ...string) [][2]string {
headers := make([][2]string, 0, len(kvs)/2)
for i := 0; i < len(kvs); i += 2 {
headers = append(headers, [2]string{kvs[i], kvs[i+1]})
}
return headers
}

View File

@@ -3,11 +3,12 @@ package main
import (
"errors"
"fmt"
"strings"
"github.com/alibaba/higress/plugins/wasm-go/pkg/wrapper"
"github.com/tidwall/gjson"
re "github.com/wasilibs/go-re2"
"github.com/zmap/go-iptree/iptree"
"strings"
)
// 限流规则项类型

View File

@@ -5,8 +5,7 @@ github.com/google/uuid v1.3.0 h1:t6JiXgmwXMjEs8VusXIJk2BXHsn+wx8BZdTaoZ5fu7I=
github.com/google/uuid v1.3.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/higress-group/nottinygc v0.0.0-20231101025119-e93c4c2f8520 h1:IHDghbGQ2DTIXHBHxWfqCYQW1fKjyJ/I7W1pMyUDeEA=
github.com/higress-group/nottinygc v0.0.0-20231101025119-e93c4c2f8520/go.mod h1:Nz8ORLaFiLWotg6GeKlJMhv8cci8mM43uEnLA5t8iew=
github.com/higress-group/proxy-wasm-go-sdk v0.0.0-20240327114451-d6b7174a84fc h1:t2AT8zb6N/59Y78lyRWedVoVWHNRSCBh0oWCC+bluTQ=
github.com/higress-group/proxy-wasm-go-sdk v0.0.0-20240327114451-d6b7174a84fc/go.mod h1:hNFjhrLUIq+kJ9bOcs8QtiplSQ61GZXtd2xHKx4BYRo=
github.com/higress-group/proxy-wasm-go-sdk v0.0.0-20240711023527-ba358c48772f h1:ZIiIBRvIw62gA5MJhuwp1+2wWbqL9IGElQ499rUsYYg=
github.com/higress-group/proxy-wasm-go-sdk v0.0.0-20240711023527-ba358c48772f/go.mod h1:hNFjhrLUIq+kJ9bOcs8QtiplSQ61GZXtd2xHKx4BYRo=
github.com/magefile/mage v1.14.0 h1:6QDX3g6z1YvJ4olPhT1wksUcSa/V0a1B+pJb73fBjyo=
github.com/magefile/mage v1.14.0/go.mod h1:z5UZb/iS3GoOSn0JgWuiw7dxlurVYTu+/jHXqQg881A=

View File

@@ -16,15 +16,16 @@ package main
import (
"fmt"
"net"
"net/url"
"strconv"
"strings"
"github.com/alibaba/higress/plugins/wasm-go/pkg/wrapper"
"github.com/higress-group/proxy-wasm-go-sdk/proxywasm"
"github.com/higress-group/proxy-wasm-go-sdk/proxywasm/types"
"github.com/tidwall/gjson"
"github.com/tidwall/resp"
"net"
"net/url"
"strconv"
"strings"
)
func main() {
@@ -88,12 +89,10 @@ func onHttpRequestHeaders(ctx wrapper.HttpContext, config ClusterKeyRateLimitCon
args := []interface{}{configItem.count, configItem.timeWindow}
// 执行限流逻辑
err := config.redisClient.Eval(FixedWindowScript, 1, keys, args, func(response resp.Value) {
defer func() {
_ = proxywasm.ResumeHttpRequest()
}()
resultArray := response.Array()
if len(resultArray) != 3 {
log.Errorf("redis response parse error, response: %v", response)
proxywasm.ResumeHttpRequest()
return
}
context := LimitContext{
@@ -106,6 +105,7 @@ func onHttpRequestHeaders(ctx wrapper.HttpContext, config ClusterKeyRateLimitCon
rejected(config, context)
} else {
ctx.SetContext(LimitContextKey, context)
proxywasm.ResumeHttpRequest()
}
})
if err != nil {

View File

@@ -2,9 +2,10 @@ package main
import (
"fmt"
"github.com/zmap/go-iptree/iptree"
"sort"
"strings"
"github.com/zmap/go-iptree/iptree"
)
// parseIPNet 解析Ip段配置

View File

@@ -271,3 +271,14 @@ Content-Length: 0
```
`ext-auth` 服务返回响应头中如果包含 `x-user-id``x-auth-version`网关调用upstream时的请求中会带上这两个请求头
#### x-forwarded-* header
在endpoint_mode为forward_auth时higress会自动生成并发送以下header至鉴权服务。
| Header | 说明 |
|--------------------|-------------------------------------|
| x-forwarded-proto | 原始请求的scheme比如http/https |
| x-forwarded-method | 原始请求的方法比如get/post/delete/patch |
| x-forwarded-host | 原始请求的host |
| x-forwarded-uri | 原始请求的path包含路径参数比如/v1/app?test=true |
| x-forwarded-for | 原始请求的客户端IP地址 |

View File

@@ -2,12 +2,13 @@ package main
import (
"errors"
"ext-auth/expr"
"fmt"
"github.com/alibaba/higress/plugins/wasm-go/pkg/wrapper"
"github.com/tidwall/gjson"
"net/http"
"strings"
"ext-auth/expr"
"github.com/alibaba/higress/plugins/wasm-go/pkg/wrapper"
"github.com/tidwall/gjson"
)
const (

View File

@@ -2,9 +2,10 @@ package expr
import (
"errors"
"strings"
"github.com/tidwall/gjson"
regexp "github.com/wasilibs/go-re2"
"strings"
)
const (

View File

@@ -1,9 +1,10 @@
package expr
import (
"testing"
"github.com/stretchr/testify/assert"
"github.com/tidwall/gjson"
"testing"
)
func TestStringMatcher(t *testing.T) {

View File

@@ -15,11 +15,12 @@
package main
import (
"net/http"
"net/url"
"github.com/alibaba/higress/plugins/wasm-go/pkg/wrapper"
"github.com/higress-group/proxy-wasm-go-sdk/proxywasm"
"github.com/higress-group/proxy-wasm-go-sdk/proxywasm/types"
"net/http"
"net/url"
)
func main() {
@@ -36,6 +37,12 @@ const (
HeaderFailureModeAllow string = "x-envoy-auth-failure-mode-allowed"
HeaderOriginalMethod string = "x-original-method"
HeaderOriginalUri string = "x-original-uri"
// Currently, x-forwarded-xxx headers only apply for forward_auth.
HeaderXForwardedProto = "x-forwarded-proto"
HeaderXForwardedMethod = "x-forwarded-method"
HeaderXForwardedUri = "x-Forwarded-uri"
HeaderXForwardedHost = "x-Forwarded-host"
)
func onHttpRequestHeaders(ctx wrapper.HttpContext, config ExtAuthConfig, log wrapper.Log) types.Action {
@@ -94,6 +101,10 @@ func checkExtAuth(ctx wrapper.HttpContext, config ExtAuthConfig, body []byte, lo
if httpServiceConfig.endpointMode == EndpointModeForwardAuth {
extAuthReqHeaders.Set(HeaderOriginalMethod, ctx.Method())
extAuthReqHeaders.Set(HeaderOriginalUri, ctx.Path())
extAuthReqHeaders.Set(HeaderXForwardedProto, ctx.Scheme())
extAuthReqHeaders.Set(HeaderXForwardedMethod, ctx.Method())
extAuthReqHeaders.Set(HeaderXForwardedUri, ctx.Path())
extAuthReqHeaders.Set(HeaderXForwardedHost, ctx.Host())
}
requestMethod := httpServiceConfig.requestMethod

View File

@@ -1,10 +1,11 @@
package main
import (
"github.com/higress-group/proxy-wasm-go-sdk/proxywasm"
"net/http"
"sort"
"strings"
"github.com/higress-group/proxy-wasm-go-sdk/proxywasm"
)
func sendResponse(statusCode uint32, statusCodeDetailData string, headers http.Header) error {

View File

@@ -3,13 +3,14 @@
`frontend-gray`插件实现了前端用户灰度的的功能,通过此插件,不但可以用于业务`A/B实验`,同时通过`可灰度`配合`可监控`,`可回滚`策略保证系统发布运维的稳定性。
## 配置字段
| 名称 | 数据类型 | 填写要求 | 默认值 | 描述 |
|----------------|--------------|------|-----|-----------------------------------------------------------------------------------|
| `grayKey` | string | 非必填 | - | 用户ID的唯一标识可以来自Cookie或者Header中比如 userid如果没有填写则使用`rules[].grayTagKey``rules[].grayTagValue`过滤灰度规则 |
| `graySubKey` | string | 非必填 | - | 用户身份信息可能以JSON形式透出比如`userInfo:{ userCode:"001" }`,当前例子`graySubKey`取值为`userCode` |
| `rules` | array of object | 必填 | - | 用户定义不同的灰度规则,适配不同的灰度场景 |
| `baseDeployment` | object | 必填 | - | 配置Base基线规则的配置 |
| `grayDeployments` | array of object | 非必填 | - | 配置Gray灰度的生效规则以及生效版本 |
| 名称 | 数据类型 | 填写要求 | 默认值 | 描述 |
|----------------|--------------|----|-----|----------------------------------------------------------------------------------------------------|
| `grayKey` | string | 非必填 | - | 用户ID的唯一标识可以来自Cookie或者Header中比如 userid如果没有填写则使用`rules[].grayTagKey``rules[].grayTagValue`过滤灰度规则 |
| `graySubKey` | string | 非必填 | - | 用户身份信息可能以JSON形式透出比如`userInfo:{ userCode:"001" }`,当前例子`graySubKey`取值为`userCode` |
| `rules` | array of object | 必填 | - | 用户定义不同的灰度规则,适配不同的灰度场景 |
| `rewrite` | object | 必填 | - | 重写配置一般用于OSS/CDN前端部署的重写配置 |
| `baseDeployment` | object | 非必填 | - | 配置Base基线规则的配置 |
| `grayDeployments` | array of object | 非必填 | - | 配置Gray灰度的生效规则以及生效版本 |
`rules`字段配置说明:
@@ -20,6 +21,19 @@
| `grayTagKey` | string | 非必填 | - | 用户分类打标的标签key值来自Cookie |
| `grayTagValue` | array of string | 非必填 | - | 用户分类打标的标签value值来自Cookie |
`rewrite`字段配置说明:
> `indexRouting`首页重写和`fileRouting`文件重写,本质都是前缀匹配,比如`/app1`: `/mfe/app1/{version}/index.html`代表/app1为前缀的请求路由到`/mfe/app1/{version}/index.html`页面上,其中`{version}`代表版本号,在运行过程中会被`baseDeployment.version`或者`grayDeployments[].version`动态替换。
> `{version}` 作为保留字段,在执行过程中会被`baseDeployment.version`或者`grayDeployments[].version`动态替换前端版本。
| 名称 | 数据类型 | 填写要求 | 默认值 | 描述 |
|------------|--------------|------|-----|------------------------------|
| `host` | string | 非必填 | - | host地址如果是OSS则设置为 VPC 内网访问地址 |
| `notFoundUri` | string | 非必填 | - | 404 页面配置 |
| `indexRouting` | map of string to string | 非必填 | - | 用于定义首页重写路由规则。每个键 (Key) 表示首页的路由路径,值 (Value) 则指向重定向的目标文件。例如,键为 `/app1` 对应的值为 `/mfe/app1/{version}/index.html`。生效version为`0.0.1` 访问路径为 `/app1`,则重定向到 `/mfe/app1/0.0.1/index.html`。 |
| `fileRouting` | map of string to string | 非必填 | - | 用于定义资源文件重写路由规则。每个键 (Key) 表示资源访问路径,值 (Value) 则指向重定向的目标文件。例如,键为 `/app1/` 对应的值为 `/mfe/app1/{version}`。生效version为`0.0.1`,访问路径为 `/app1/js/a.js`,则重定向到 `/mfe/app1/0.0.1/js/a.js`。 |
`baseDeployment`字段配置说明:
| 名称 | 数据类型 | 填写要求 | 默认值 | 描述 |
@@ -28,11 +42,12 @@
`grayDeployments`字段配置说明:
| 名称 | 数据类型 | 填写要求 | 默认值 | 描述 |
|--------|--------|------|-----|----------------------------|
| `version` | string | 必填 | - | Gray版本的版本号如果命中灰度规则则使用此版本 |
| `name` | string | 必填 | - | 规则名称和`rules[].name`关联, |
| `enabled` | boolean | 必填 | - | 是否启动当前灰度规则 |
| 名称 | 数据类型 | 填写要求 | 默认值 | 描述 |
|--------|--------|------|-----|-------------------------------------------------|
| `version` | string | 必填 | - | Gray版本的版本号如果命中灰度规则则使用此版本。如果是非CDN部署在header添加`x-higress-tag` |
| `backendVersion` | string | 必填 | - | 后端灰度版本,会在`XHR/Fetch`请求的header头添加 `x-mse-tag`到后端 |
| `name` | string | 必填 | - | 规则名称和`rules[].name`关联, |
| `enabled` | boolean | 必填 | - | 是否启动当前灰度规则 |
## 配置示例
### 基础配置
@@ -100,4 +115,51 @@ cookie存在`appInfo`的JSON数据其中包含`userId`字段为当前的唯
- cookie中`userid`等于`00000002`或者`00000003`
- cookie中`level`等于`level3`或者`level5`的用户
否则使用`version: base`版本
否则使用`version: base`版本
### rewrite重写配置
> 一般用于CDN部署场景
```yml
grayKey: userid
rules:
- name: inner-user
grayKeyValue:
- '00000001'
- '00000005'
- name: beta-user
grayKeyValue:
- '00000002'
- '00000003'
grayTagKey: level
grayTagValue:
- level3
- level5
rewrite:
host: frontend-gray.oss-cn-shanghai-internal.aliyuncs.com
notFoundUri: /mfe/app1/dev/404.html
indexRouting:
/app1: '/mfe/app1/{version}/index.html'
/: '/mfe/app1/{version}/index.html',
fileRouting:
/: '/mfe/app1/{version}'
/app1/: '/mfe/app1/{version}'
baseDeployment:
version: base
grayDeployments:
- name: beta-user
version: gray
enabled: true
```
`{version}`会在运行过程中动态替换为真正的版本
#### indexRouting首页路由配置
访问 `/app1`, `/app123`,`/app1/index.html`, `/app1/xxx`, `/xxxx` 都会路由到'/mfe/app1/{version}/index.html'
#### fileRouting文件路由配置
下面文件映射均生效
- `/js/a.js` => `/mfe/app1/v1.0.0/js/a.js`
- `/js/template/a.js` => `/mfe/app1/v1.0.0/js/template/a.js`
- `/app1/js/a.js` => `/mfe/app1/v1.0.0/js/a.js`
- `/app1/js/template/a.js` => `/mfe/app1/v1.0.0/js/template/a.js`

View File

@@ -1,16 +1,25 @@
package config
import (
"strconv"
"github.com/tidwall/gjson"
)
const (
XHigressTag = "x-higress-tag"
XPreHigressTag = "x-pre-higress-tag"
XMseTag = "x-mse-tag"
IsHTML = "is_html"
IsIndex = "is_index"
NotFound = "not_found"
)
type LogInfo func(format string, args ...interface{})
type GrayRule struct {
Name string
GrayKeyValue []interface{}
GrayKeyValue []string
GrayTagKey string
GrayTagValue []interface{}
GrayTagValue []string
}
type BaseDeployment struct {
@@ -18,35 +27,46 @@ type BaseDeployment struct {
Version string
}
type GrayDeployments struct {
Name string
Version string
Enabled bool
type GrayDeployment struct {
Name string
Enabled bool
Version string
BackendVersion string
}
type Rewrite struct {
Host string
NotFound string
Index map[string]string
File map[string]string
}
type GrayConfig struct {
GrayKey string
GraySubKey string
Rules []*GrayRule
Rewrite *Rewrite
BaseDeployment *BaseDeployment
GrayDeployments []*GrayDeployments
GrayDeployments []*GrayDeployment
}
func interfacesFromJSONResult(results []gjson.Result) []interface{} {
var interfaces []interface{}
for _, result := range results {
switch v := result.Value().(type) {
case float64:
// 当 v 是 float64 时,将其转换为字符串
interfaces = append(interfaces, strconv.FormatFloat(v, 'f', -1, 64))
default:
// 其它类型不改变,直接追加
interfaces = append(interfaces, v)
}
func convertToStringList(results []gjson.Result) []string {
interfaces := make([]string, len(results)) // 预分配切片容量
for i, result := range results {
interfaces[i] = result.String() // 使用 String() 方法直接获取字符串
}
return interfaces
}
func convertToStringMap(result gjson.Result) map[string]string {
m := make(map[string]string)
result.ForEach(func(key, value gjson.Result) bool {
m[key.String()] = value.String()
return true // keep iterating
})
return m
}
func JsonToGrayConfig(json gjson.Result, grayConfig *GrayConfig) {
// 解析 GrayKey
grayConfig.GrayKey = json.Get("grayKey").String()
@@ -57,14 +77,20 @@ func JsonToGrayConfig(json gjson.Result, grayConfig *GrayConfig) {
for _, rule := range rules {
grayRule := GrayRule{
Name: rule.Get("name").String(),
GrayKeyValue: interfacesFromJSONResult(rule.Get("grayKeyValue").Array()), // 使用辅助函数将 []gjson.Result 转换为 []interface{}
GrayKeyValue: convertToStringList(rule.Get("grayKeyValue").Array()),
GrayTagKey: rule.Get("grayTagKey").String(),
GrayTagValue: interfacesFromJSONResult(rule.Get("grayTagValue").Array()),
GrayTagValue: convertToStringList(rule.Get("grayTagValue").Array()),
}
grayConfig.Rules = append(grayConfig.Rules, &grayRule)
}
grayConfig.Rewrite = &Rewrite{
Host: json.Get("rewrite.host").String(),
NotFound: json.Get("rewrite.notFoundUri").String(),
Index: convertToStringMap(json.Get("rewrite.indexRouting")),
File: convertToStringMap(json.Get("rewrite.fileRouting")),
}
// 解析 deploy
// 解析 deployment
baseDeployment := json.Get("baseDeployment")
grayDeployments := json.Get("grayDeployments").Array()
@@ -73,10 +99,11 @@ func JsonToGrayConfig(json gjson.Result, grayConfig *GrayConfig) {
Version: baseDeployment.Get("version").String(),
}
for _, item := range grayDeployments {
grayConfig.GrayDeployments = append(grayConfig.GrayDeployments, &GrayDeployments{
Name: item.Get("name").String(),
Version: item.Get("version").String(),
Enabled: item.Get("enabled").Bool(),
grayConfig.GrayDeployments = append(grayConfig.GrayDeployments, &GrayDeployment{
Name: item.Get("name").String(),
Enabled: item.Get("enabled").Bool(),
Version: item.Get("version").String(),
BackendVersion: item.Get("backendVersion").String(),
})
}
}

View File

@@ -47,8 +47,7 @@ static_resources:
"@type": "type.googleapis.com/google.protobuf.StringValue"
value: |
{
"grayKey": "UserInfo",
"graySubKey": "userCode",
"grayKey": "userId",
"rules": [
{
"name": "inner-user",
@@ -70,13 +69,26 @@ static_resources:
]
}
],
"rewrite": {
"host": "frontend-gray-cn-shanghai.oss-cn-shanghai-internal.aliyuncs.com",
"notFoundUri": "/mfe/app1/dev/404.html",
"indexRouting": {
"/app1": "/mfe/app1/{version}/index.html",
"/": "/mfe/app1/{version}/index.html"
},
"fileRouting": {
"/": "/mfe/app1/{version}",
"/app1": "/mfe/app1/{version}"
}
},
"baseDeployment": {
"version": "base"
"version": "dev"
},
"grayDeployments": [
{
"name": "beta-user",
"version": "gray",
"version": "0.0.1",
"backendVersion": "beta",
"enabled": true
}
]
@@ -98,5 +110,5 @@ static_resources:
- endpoint:
address:
socket_address:
address: httpbin.org
address: frontend-gray-cn-shanghai.oss-cn-shanghai.aliyuncs.com
port_value: 80

View File

@@ -5,9 +5,9 @@ go 1.18
replace github.com/alibaba/higress/plugins/wasm-go => ../..
require (
github.com/alibaba/higress/plugins/wasm-go v0.0.0-20240531060402-2807ddfbb79e
github.com/alibaba/higress/plugins/wasm-go v1.4.3-0.20240727022514-bccfbde62188
github.com/higress-group/proxy-wasm-go-sdk v0.0.0-20240711023527-ba358c48772f
github.com/stretchr/testify v1.8.4
github.com/stretchr/testify v1.9.0
github.com/tidwall/gjson v1.17.0
)

View File

@@ -1,24 +1,20 @@
github.com/alibaba/higress/plugins/wasm-go v0.0.0-20240531060402-2807ddfbb79e h1:0b2UXrEpotHwWgwvgvkXnyKWuxTXtzfKu6c2YpRV+zw=
github.com/alibaba/higress/plugins/wasm-go v0.0.0-20240531060402-2807ddfbb79e/go.mod h1:10jQXKsYFUF7djs+Oy7t82f4dbie9pISfP9FJwpPLuk=
github.com/alibaba/higress/plugins/wasm-go v1.3.5 h1:VOLL3m442IHCSu8mR5AZ4sc6LVT9X0w1hdqDI7oB9jY=
github.com/alibaba/higress/plugins/wasm-go v1.3.5/go.mod h1:kr3V9Ntbspj1eSrX8rgjBsdMXkGupYEf+LM72caGPQc=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1+incompatible/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/google/uuid v1.3.0 h1:t6JiXgmwXMjEs8VusXIJk2BXHsn+wx8BZdTaoZ5fu7I=
github.com/google/uuid v1.3.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/higress-group/nottinygc v0.0.0-20231101025119-e93c4c2f8520 h1:IHDghbGQ2DTIXHBHxWfqCYQW1fKjyJ/I7W1pMyUDeEA=
github.com/higress-group/nottinygc v0.0.0-20231101025119-e93c4c2f8520/go.mod h1:Nz8ORLaFiLWotg6GeKlJMhv8cci8mM43uEnLA5t8iew=
github.com/higress-group/proxy-wasm-go-sdk v0.0.0-20240226065437-8f7a0b3c9071 h1:STb5rOHRZOzoiAa+gTz2LFqO1nYj7U/1eIVUJJadU4A=
github.com/higress-group/proxy-wasm-go-sdk v0.0.0-20240226065437-8f7a0b3c9071/go.mod h1:hNFjhrLUIq+kJ9bOcs8QtiplSQ61GZXtd2xHKx4BYRo=
github.com/higress-group/proxy-wasm-go-sdk v0.0.0-20240327114451-d6b7174a84fc h1:t2AT8zb6N/59Y78lyRWedVoVWHNRSCBh0oWCC+bluTQ=
github.com/higress-group/proxy-wasm-go-sdk v0.0.0-20240327114451-d6b7174a84fc/go.mod h1:hNFjhrLUIq+kJ9bOcs8QtiplSQ61GZXtd2xHKx4BYRo=
github.com/higress-group/proxy-wasm-go-sdk v0.0.0-20240711023527-ba358c48772f h1:ZIiIBRvIw62gA5MJhuwp1+2wWbqL9IGElQ499rUsYYg=
github.com/higress-group/proxy-wasm-go-sdk v0.0.0-20240711023527-ba358c48772f/go.mod h1:hNFjhrLUIq+kJ9bOcs8QtiplSQ61GZXtd2xHKx4BYRo=
github.com/magefile/mage v1.14.0 h1:6QDX3g6z1YvJ4olPhT1wksUcSa/V0a1B+pJb73fBjyo=
github.com/magefile/mage v1.14.0/go.mod h1:z5UZb/iS3GoOSn0JgWuiw7dxlurVYTu+/jHXqQg881A=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk=
github.com/pmezard/go-difflib v1.0.0+incompatible/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg=
github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
github.com/tidwall/gjson v1.17.0 h1:/Jocvlh98kcTfpN2+JzGQWQcqrPQwDrVEMApx/M5ZwM=
github.com/tidwall/gjson v1.17.0/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk=
github.com/tidwall/match v1.1.1 h1:+Ho715JplO36QYgwN9PGYNhgZvoUSc9X2c80KVTi+GA=
@@ -27,7 +23,6 @@ github.com/tidwall/pretty v1.2.0 h1:RWIZEg2iJ8/g6fDDYzMpobmaoGh5OLl4AXtGUGPcqCs=
github.com/tidwall/pretty v1.2.0/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU=
github.com/tidwall/resp v0.1.1 h1:Ly20wkhqKTmDUPlyM1S7pWo5kk0tDu8OoC/vFArXmwE=
github.com/tidwall/resp v0.1.1/go.mod h1:3/FrruOBAxPTPtundW0VXgmsQ4ZBA0Aw714lVYgwFa0=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=

View File

@@ -1,6 +1,10 @@
package main
import (
"fmt"
"net/http"
"strings"
"github.com/alibaba/higress/plugins/wasm-go/extensions/frontend-gray/config"
"github.com/alibaba/higress/plugins/wasm-go/extensions/frontend-gray/util"
@@ -15,6 +19,9 @@ func main() {
"frontend-gray",
wrapper.ParseConfigBy(parseConfig),
wrapper.ProcessRequestHeadersBy(onHttpRequestHeaders),
wrapper.ProcessResponseHeadersBy(onHttpResponseHeader),
wrapper.ProcessResponseBodyBy(onHttpResponseBody),
wrapper.ProcessStreamingResponseBodyBy(onStreamingResponseBody),
)
}
@@ -24,55 +31,146 @@ func parseConfig(json gjson.Result, grayConfig *config.GrayConfig, log wrapper.L
return nil
}
// FilterGrayRule 过滤灰度规则
func FilterGrayRule(grayConfig *config.GrayConfig, grayKeyValue string, log wrapper.Log) *config.GrayDeployments {
for _, grayDeployment := range grayConfig.GrayDeployments {
if !grayDeployment.Enabled {
// 跳过Enabled=false
continue
}
grayRule := util.GetRule(grayConfig.Rules, grayDeployment.Name)
// 首先先校验用户名单ID
if grayRule.GrayKeyValue != nil && len(grayRule.GrayKeyValue) > 0 && grayKeyValue != "" {
if util.Contains(grayRule.GrayKeyValue, grayKeyValue) {
log.Infof("x-mse-tag: %s, grayKeyValue: %s", grayDeployment.Version, grayKeyValue)
return grayDeployment
}
}
// 第二校验Cookie中的 GrayTagKey
if grayRule.GrayTagKey != "" && grayRule.GrayTagValue != nil && len(grayRule.GrayTagValue) > 0 {
cookieStr, _ := proxywasm.GetHttpRequestHeader("cookie")
grayTagValue := util.GetValueByCookie(cookieStr, grayRule.GrayTagKey)
if util.Contains(grayRule.GrayTagValue, grayTagValue) {
log.Infof("x-mse-tag: %s, grayTag: %s=%s", grayDeployment.Version, grayRule.GrayTagKey, grayTagValue)
return grayDeployment
}
}
func onHttpRequestHeaders(ctx wrapper.HttpContext, grayConfig config.GrayConfig, log wrapper.Log) types.Action {
if !util.IsGrayEnabled(grayConfig) {
return types.ActionContinue
}
log.Infof("x-mse-tag: %s, grayKeyValue: %s", grayConfig.BaseDeployment.Version, grayKeyValue)
return nil
cookies, _ := proxywasm.GetHttpRequestHeader("cookie")
path, _ := proxywasm.GetHttpRequestHeader(":path")
fetchMode, _ := proxywasm.GetHttpRequestHeader("sec-fetch-mode")
isIndex := util.IsIndexRequest(fetchMode, path)
hasRewrite := len(grayConfig.Rewrite.File) > 0 || len(grayConfig.Rewrite.Index) > 0
grayKeyValue := util.GetGrayKey(util.ExtractCookieValueByKey(cookies, grayConfig.GrayKey), grayConfig.GraySubKey)
// 如果有重写的配置,则进行重写
if hasRewrite {
// 禁止重新路由要在更改Header之前操作否则会失效
ctx.DisableReroute()
}
// 删除Accept-Encoding避免压缩 如果是压缩的内容,后续插件就没法处理了
_ = proxywasm.RemoveHttpRequestHeader("Accept-Encoding")
_ = proxywasm.RemoveHttpRequestHeader("Content-Length")
grayDeployment := util.FilterGrayRule(&grayConfig, grayKeyValue, log.Infof)
frontendVersion := util.GetVersion(grayConfig.BaseDeployment.Version, cookies, isIndex)
backendVersion := ""
// 命中灰度规则
if grayDeployment != nil {
frontendVersion = util.GetVersion(grayDeployment.Version, cookies, isIndex)
backendVersion = grayDeployment.BackendVersion
}
proxywasm.AddHttpRequestHeader(config.XHigressTag, frontendVersion)
ctx.SetContext(config.XPreHigressTag, frontendVersion)
ctx.SetContext(config.XMseTag, backendVersion)
ctx.SetContext(config.IsIndex, isIndex)
rewrite := grayConfig.Rewrite
if rewrite.Host != "" {
proxywasm.ReplaceHttpRequestHeader("HOST", rewrite.Host)
}
if hasRewrite {
rewritePath := path
if isIndex {
rewritePath = util.IndexRewrite(path, frontendVersion, grayConfig.Rewrite.Index)
} else {
rewritePath = util.PrefixFileRewrite(path, frontendVersion, grayConfig.Rewrite.File)
}
log.Infof("rewrite path: %s %s %v", path, frontendVersion, rewritePath)
proxywasm.ReplaceHttpRequestHeader(":path", rewritePath)
}
return types.ActionContinue
}
func onHttpRequestHeaders(ctx wrapper.HttpContext, grayConfig config.GrayConfig, log wrapper.Log) types.Action {
// 优先从cookie中获取如果拿不到再从header中获取
cookieStr, _ := proxywasm.GetHttpRequestHeader("cookie")
grayHeaderKey, _ := proxywasm.GetHttpRequestHeader(grayConfig.GrayKey)
grayKeyValue := util.GetValueByCookie(cookieStr, grayConfig.GrayKey)
proxywasm.RemoveHttpRequestHeader("Accept-Encoding")
// 优先从Cookie中获取否则从header中获取
if grayKeyValue == "" {
grayKeyValue = grayHeaderKey
func onHttpResponseHeader(ctx wrapper.HttpContext, grayConfig config.GrayConfig, log wrapper.Log) types.Action {
if !util.IsGrayEnabled(grayConfig) {
return types.ActionContinue
}
// 如果有子key, 尝试从子key中获取值
if grayConfig.GraySubKey != "" {
subKeyValue := util.GetBySubKey(grayKeyValue, grayConfig.GraySubKey)
if subKeyValue != "" {
grayKeyValue = subKeyValue
status, err := proxywasm.GetHttpResponseHeader(":status")
contentType, _ := proxywasm.GetHttpResponseHeader("Content-Type")
if err != nil || status != "200" {
isIndex := ctx.GetContext(config.IsIndex)
if status == "404" {
if grayConfig.Rewrite.NotFound != "" && isIndex != nil && isIndex.(bool) {
ctx.SetContext(config.NotFound, true)
responseHeaders, _ := proxywasm.GetHttpResponseHeaders()
headersMap := util.ConvertHeaders(responseHeaders)
headersMap[":status"][0] = "200"
headersMap["content-type"][0] = "text/html"
delete(headersMap, "content-length")
proxywasm.ReplaceHttpResponseHeaders(util.ReconvertHeaders(headersMap))
ctx.BufferResponseBody()
return types.ActionContinue
} else {
ctx.DontReadResponseBody()
}
}
log.Errorf("error status: %s, error message: %v", status, err)
return types.ActionContinue
}
grayDeployment := FilterGrayRule(&grayConfig, grayKeyValue, log)
if grayDeployment != nil {
proxywasm.AddHttpRequestHeader("x-mse-tag", grayDeployment.Version)
// 删除content-length可能要修改Response返回值
proxywasm.RemoveHttpResponseHeader("Content-Length")
// 删除Content-Disposition避免自动下载文件
proxywasm.RemoveHttpResponseHeader("Content-Disposition")
if strings.HasPrefix(contentType, "text/html") {
ctx.SetContext(config.IsHTML, true)
// 不会进去Streaming 的Body处理
ctx.BufferResponseBody()
// 添加Cache-Control 头部,禁止缓存
proxywasm.ReplaceHttpRequestHeader("Cache-Control", "no-cache, no-store")
frontendVersion := ctx.GetContext(config.XPreHigressTag).(string)
backendVersion := ctx.GetContext(config.XMseTag).(string)
// 设置当前的前端版本
proxywasm.AddHttpResponseHeader("Set-Cookie", fmt.Sprintf("%s=%s; Path=/;", config.XPreHigressTag, frontendVersion))
// 设置后端的前端版本
proxywasm.AddHttpResponseHeader("Set-Cookie", fmt.Sprintf("%s=%s; Path=/;", config.XMseTag, backendVersion))
}
return types.ActionContinue
}
func onHttpResponseBody(ctx wrapper.HttpContext, grayConfig config.GrayConfig, body []byte, log wrapper.Log) types.Action {
if !util.IsGrayEnabled(grayConfig) {
return types.ActionContinue
}
backendVersion := ctx.GetContext(config.XMseTag)
isHtml := ctx.GetContext(config.IsHTML)
isIndex := ctx.GetContext(config.IsIndex)
notFoundUri := ctx.GetContext(config.NotFound)
if isIndex != nil && isIndex.(bool) && notFoundUri != nil && notFoundUri.(bool) && grayConfig.Rewrite.Host != "" && grayConfig.Rewrite.NotFound != "" {
client := wrapper.NewClusterClient(wrapper.RouteCluster{Host: grayConfig.Rewrite.Host})
client.Get(grayConfig.Rewrite.NotFound, nil, func(statusCode int, responseHeaders http.Header, responseBody []byte) {
proxywasm.ReplaceHttpResponseBody(responseBody)
proxywasm.ResumeHttpResponse()
}, 1500)
return types.ActionPause
}
// 以text/html 开头,将 cookie转到cookie
if isHtml != nil && isHtml.(bool) && backendVersion != nil && backendVersion.(string) != "" {
newText := strings.ReplaceAll(string(body), "</head>", `<script>
!function(e,t){function n(e){var n="; "+t.cookie,r=n.split("; "+e+"=");return 2===r.length?r.pop().split(";").shift():null}var r=n("x-mse-tag");if(!r)return null;var s=XMLHttpRequest.prototype.open;XMLHttpRequest.prototype.open=function(e,t,n,a,i){return this._XHR=!0,this.addEventListener("readystatechange",function(){1===this.readyState&&r&&this.setRequestHeader("x-mse-tag",r)}),s.apply(this,arguments)};var a=e.fetch;e.fetch=function(e,t){return"undefined"==typeof t&&(t={}),"undefined"==typeof t.headers&&(t.headers={}),r&&(t.headers["x-mse-tag"]=r),a.apply(this,[e,t])}}(window,document);
</script>
</head>`)
if err := proxywasm.ReplaceHttpResponseBody([]byte(newText)); err != nil {
return types.ActionContinue
}
}
return types.ActionContinue
}
func onStreamingResponseBody(ctx wrapper.HttpContext, pluginConfig config.GrayConfig, chunk []byte, isLastChunk bool, log wrapper.Log) []byte {
return chunk
}

View File

@@ -2,39 +2,56 @@ package util
import (
"net/url"
"path"
"path/filepath"
"sort"
"strings"
"github.com/higress-group/proxy-wasm-go-sdk/proxywasm"
"github.com/alibaba/higress/plugins/wasm-go/extensions/frontend-gray/config"
"github.com/tidwall/gjson"
)
// GetValueByCookie 根据 cookieStr 和 cookieName 获取 cookie 值
func GetValueByCookie(cookieStr string, cookieName string) string {
if cookieStr == "" {
func IsGrayEnabled(grayConfig config.GrayConfig) bool {
// 检查是否存在重写主机
if grayConfig.Rewrite != nil && grayConfig.Rewrite.Host != "" {
return true
}
// 检查灰度部署是否为 nil 或空
grayDeployments := grayConfig.GrayDeployments
if grayDeployments != nil && len(grayDeployments) > 0 {
for _, grayDeployment := range grayDeployments {
if grayDeployment.Enabled {
return true
}
}
}
return false
}
// ExtractCookieValueByKey 根据 cookie 和 key 获取 cookie 值
func ExtractCookieValueByKey(cookie string, key string) string {
if cookie == "" {
return ""
}
cookies := strings.Split(cookieStr, ";")
curCookieName := cookieName + "="
var foundCookieValue string
var found bool
// 遍历找到 cookie 对并处理
for _, cookie := range cookies {
cookie = strings.TrimSpace(cookie) // 清理空白符
if strings.HasPrefix(cookie, curCookieName) {
foundCookieValue = cookie[len(curCookieName):]
found = true
value := ""
pairs := strings.Split(cookie, ";")
for _, pair := range pairs {
pair = strings.TrimSpace(pair)
kv := strings.Split(pair, "=")
if kv[0] == key {
value = kv[1]
break
}
}
if !found {
return ""
}
return foundCookieValue
return value
}
// contains 检查切片 slice 中是否含有元素 value。
func Contains(slice []interface{}, value string) bool {
func ContainsValue(slice []string, value string) bool {
for _, item := range slice {
if item == value {
return true
@@ -43,6 +60,30 @@ func Contains(slice []interface{}, value string) bool {
return false
}
// headers: [][2]string -> map[string][]string
func ConvertHeaders(hs [][2]string) map[string][]string {
ret := make(map[string][]string)
for _, h := range hs {
k, v := strings.ToLower(h[0]), h[1]
ret[k] = append(ret[k], v)
}
return ret
}
// headers: map[string][]string -> [][2]string
func ReconvertHeaders(hs map[string][]string) [][2]string {
var ret [][2]string
for k, vs := range hs {
for _, v := range vs {
ret = append(ret, [2]string{k, v})
}
}
sort.SliceStable(ret, func(i, j int) bool {
return ret[i][0] < ret[j][0]
})
return ret
}
func GetRule(rules []*config.GrayRule, name string) *config.GrayRule {
for _, rule := range rules {
if rule.Name == name {
@@ -52,7 +93,66 @@ func GetRule(rules []*config.GrayRule, name string) *config.GrayRule {
return nil
}
func GetBySubKey(grayInfoStr string, graySubKey string) string {
// 检查是否是页面
var indexSuffixes = []string{
".html", ".htm", ".jsp", ".php", ".asp", ".aspx", ".erb", ".ejs", ".twig",
}
// IsIndexRequest determines if the request is an index request
func IsIndexRequest(fetchMode string, p string) bool {
if fetchMode == "cors" {
return false
}
ext := path.Ext(p)
return ext == "" || ContainsValue(indexSuffixes, ext)
}
// 首页Rewrite
func IndexRewrite(path, version string, matchRules map[string]string) string {
for prefix, rewrite := range matchRules {
if strings.HasPrefix(path, prefix) {
newPath := strings.Replace(rewrite, "{version}", version, -1)
return newPath
}
}
return path
}
func PrefixFileRewrite(path, version string, matchRules map[string]string) string {
var matchedPrefix, replacement string
for prefix, template := range matchRules {
if strings.HasPrefix(path, prefix) {
if len(prefix) > len(matchedPrefix) { // 找到更长的前缀
matchedPrefix = prefix
replacement = strings.Replace(template, "{version}", version, 1)
}
}
}
// 将path 中的前缀部分用 replacement 替换掉
newPath := strings.Replace(path, matchedPrefix, replacement+"/", 1)
return filepath.Clean(newPath)
}
func GetVersion(version string, cookies string, isIndex bool) string {
if isIndex {
return version
}
// 来自Cookie中的版本
cookieVersion := ExtractCookieValueByKey(cookies, config.XPreHigressTag)
// cookie 中为空,返回当前版本
if cookieVersion == "" {
return version
}
// cookie 中和当前版本不相同返回cookie中值
if cookieVersion != version {
return cookieVersion
}
return version
}
// 从cookie中解析出灰度信息
func getBySubKey(grayInfoStr string, graySubKey string) string {
// 首先对 URL 编码的字符串进行解码
jsonStr, err := url.QueryUnescape(grayInfoStr)
if err != nil {
@@ -68,3 +168,43 @@ func GetBySubKey(grayInfoStr string, graySubKey string) string {
// 返回字符串形式的值
return value.String()
}
func GetGrayKey(grayKeyValue string, graySubKey string) string {
// 如果有子key, 尝试从子key中获取值
if graySubKey != "" {
subKeyValue := getBySubKey(grayKeyValue, graySubKey)
if subKeyValue != "" {
grayKeyValue = subKeyValue
}
}
return grayKeyValue
}
// FilterGrayRule 过滤灰度规则
func FilterGrayRule(grayConfig *config.GrayConfig, grayKeyValue string, logInfof func(format string, args ...interface{})) *config.GrayDeployment {
for _, grayDeployment := range grayConfig.GrayDeployments {
if !grayDeployment.Enabled {
// 跳过Enabled=false
continue
}
grayRule := GetRule(grayConfig.Rules, grayDeployment.Name)
// 首先先校验用户名单ID
if grayRule.GrayKeyValue != nil && len(grayRule.GrayKeyValue) > 0 && grayKeyValue != "" {
if ContainsValue(grayRule.GrayKeyValue, grayKeyValue) {
logInfof("frontendVersion: %s, grayKeyValue: %s", grayDeployment.Version, grayKeyValue)
return grayDeployment
}
}
// 第二校验Cookie中的 GrayTagKey
if grayRule.GrayTagKey != "" && grayRule.GrayTagValue != nil && len(grayRule.GrayTagValue) > 0 {
cookieStr, _ := proxywasm.GetHttpRequestHeader("cookie")
grayTagValue := ExtractCookieValueByKey(cookieStr, grayRule.GrayTagKey)
if ContainsValue(grayRule.GrayTagValue, grayTagValue) {
logInfof("frontendVersion: %s, grayTag: %s=%s", grayDeployment.Version, grayRule.GrayTagKey, grayTagValue)
return grayDeployment
}
}
}
logInfof("frontendVersion: %s, grayKeyValue: %s", grayConfig.BaseDeployment.Version, grayKeyValue)
return nil
}

View File

@@ -6,7 +6,7 @@ import (
"github.com/stretchr/testify/assert"
)
func TestGetValueByCookie(t *testing.T) {
func TestExtractCookieValueByKey(t *testing.T) {
var tests = []struct {
cookie, cookieKey, output string
}{
@@ -19,23 +19,85 @@ func TestGetValueByCookie(t *testing.T) {
for _, test := range tests {
testName := test.cookie
t.Run(testName, func(t *testing.T) {
output := GetValueByCookie(test.cookie, test.cookieKey)
output := ExtractCookieValueByKey(test.cookie, test.cookieKey)
assert.Equal(t, test.output, output)
})
}
}
func TestDecodeJsonCookie(t *testing.T) {
// 测试首页Rewrite重写
func TestIndexRewrite(t *testing.T) {
matchRules := map[string]string{
"/app1": "/mfe/app1/{version}/index.html",
"/": "/mfe/app1/{version}/index.html",
}
var tests = []struct {
userInfoStr, grayJsonKey, output string
path, output string
}{
{"{%22password%22:%22$2a$10$YAvYjA6783YeCi44/M395udIZ4Ll2iyKkQCzePaYx5NNG/aIWgICG%22%2C%22username%22:%22%E8%B0%A2%E6%99%AE%E8%80%80%22%2C%22authorities%22:[]%2C%22accountNonExpired%22:true%2C%22accountNonLocked%22:true%2C%22credentialsNonExpired%22:true%2C%22enabledd%22:true%2C%22id%22:838925798835720200%2C%22mobile%22:%22%22%2C%22userCode%22:%22noah%22%2C%22userName%22:%22%E8%B0%A2%E6%99%AE%E8%80%80%22%2C%22orgId%22:10%2C%22ocId%22:87%2C%22userType%22:%22OWN%22%2C%22firstLogin%22:false%2C%22ownOrgId%22:null%2C%22clientCode%22:%22%22%2C%22clientType%22:null%2C%22country%22:%22UAE%22%2C%22isGuide%22:null%2C%22acctId%22:null%2C%22userToken%22:null%2C%22deviceId%22:%223a47fec00a59d140%22%2C%22ocCode%22:%2299990002%22%2C%22secondType%22:%22dtl%22%2C%22vendorCode%22:%2210000001%22%2C%22status%22:%22ACTIVE%22%2C%22isDelete%22:false%2C%22email%22:%22%22%2C%22deleteStatus%22:null%2C%22deleteRequestDate%22:null%2C%22wechatId%22:null%2C%22userMfaInfoDTO%22:{%22checkMfa%22:false%2C%22checkSuccess%22:false%2C%22mobile%22:null%2C%22email%22:null%2C%22wechatId%22:null%2C%22totpSecret%22:null}}",
"userCode", "noah"},
{"/app1/", "/mfe/app1/v1.0.0/index.html"},
{"/app123", "/mfe/app1/v1.0.0/index.html"},
{"/app1/index.html", "/mfe/app1/v1.0.0/index.html"},
{"/app1/index.jsp", "/mfe/app1/v1.0.0/index.html"},
{"/app1/xxx", "/mfe/app1/v1.0.0/index.html"},
{"/xxxx", "/mfe/app1/v1.0.0/index.html"},
}
for _, test := range tests {
testName := test.userInfoStr
testName := test.path
t.Run(testName, func(t *testing.T) {
output := GetBySubKey(test.userInfoStr, test.grayJsonKey)
output := IndexRewrite(testName, "v1.0.0", matchRules)
assert.Equal(t, test.output, output)
})
}
}
func TestPrefixFileRewrite(t *testing.T) {
matchRules := map[string]string{
// 前缀匹配
"/": "/mfe/app1/{version}",
"/app2/": "/mfe/app1/{version}",
"/app1/": "/mfe/app1/{version}",
"/app1/prefix2": "/mfe/app1/{version}",
"/mfe/app1": "/mfe/app1/{version}",
}
var tests = []struct {
path, output string
}{
{"/js/a.js", "/mfe/app1/v1.0.0/js/a.js"},
{"/app2/js/a.js", "/mfe/app1/v1.0.0/js/a.js"},
{"/app1/js/a.js", "/mfe/app1/v1.0.0/js/a.js"},
{"/app1/prefix2/js/a.js", "/mfe/app1/v1.0.0/js/a.js"},
{"/app1/prefix2/js/a.js", "/mfe/app1/v1.0.0/js/a.js"},
{"/mfe/app1/js/a.js", "/mfe/app1/v1.0.0/js/a.js"},
}
for _, test := range tests {
testName := test.path
t.Run(testName, func(t *testing.T) {
output := PrefixFileRewrite(testName, "v1.0.0", matchRules)
assert.Equal(t, test.output, output)
})
}
}
func TestIsIndexRequest(t *testing.T) {
var tests = []struct {
fetchMode string
p string
output bool
}{
{"cors", "/js/a.js", false},
{"no-cors", "/js/a.js", false},
{"no-cors", "/images/a.png", false},
{"no-cors", "/index", true},
{"cors", "/inde", false},
{"no-cors", "/index.html", true},
{"no-cors", "/demo.php", true},
}
for _, test := range tests {
testPath := test.p
t.Run(testPath, func(t *testing.T) {
output := IsIndexRequest(test.fetchMode, testPath)
assert.Equal(t, test.output, output)
})
}

View File

@@ -4,15 +4,22 @@ version = 3
[[package]]
name = "ahash"
version = "0.8.3"
version = "0.8.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2c99f64d1e06488f620f932677e24bc6e2897582980441ae90a671415bd7ec2f"
checksum = "e89da841a80418a9b391ebaea17f5c112ffaaa96f621d2c285b5174da76b9011"
dependencies = [
"cfg-if",
"once_cell",
"version_check",
"zerocopy",
]
[[package]]
name = "allocator-api2"
version = "0.2.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5c6cb57a04249c6480766f7f7cef5467412af1490f8d1e243141daddada3264f"
[[package]]
name = "cfg-if"
version = "1.0.0"
@@ -21,9 +28,9 @@ checksum = "baf1de4339761588bc0619e3cbc0120ee582ebb74b53b4efbf79117bd2da40fd"
[[package]]
name = "getrandom"
version = "0.2.9"
version = "0.2.15"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c85e1d9ab2eadba7e5040d4e09cbd6d072b76a557ad64e797c2cb9d4da21d7e4"
checksum = "c4567c8db10ae91089c99af84c68c38da3ec2f087c3f82960bcdbf3656b6f4d7"
dependencies = [
"cfg-if",
"libc",
@@ -32,11 +39,12 @@ dependencies = [
[[package]]
name = "hashbrown"
version = "0.13.2"
version = "0.14.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "43a3c133739dddd0d2990f9a4bdf8eb4b21ef50e4851ca85ab661199821d510e"
checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1"
dependencies = [
"ahash",
"allocator-api2",
]
[[package]]
@@ -52,24 +60,27 @@ dependencies = [
[[package]]
name = "itoa"
version = "1.0.6"
version = "1.0.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "453ad9f582a441959e5f0d088b02ce04cfe8d51a8eaf077f12ac6d3e94164ca6"
checksum = "49f1f14873335454500d59611f1cf4a4b0f786f9ac11f4312a78e4cf2566695b"
[[package]]
name = "libc"
version = "0.2.144"
version = "0.2.155"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2b00cc1c228a6782d0f076e7b232802e0c5689d41bb5df366f2a6b6621cfdfe1"
checksum = "97b3888a4aecf77e811145cadf6eef5901f4782c53886191b2f693f24761847c"
[[package]]
name = "log"
version = "0.4.17"
version = "0.4.22"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "abb12e687cfb44aa40f41fc3978ef76448f9b6038cad6aef4259d3c095a2382e"
dependencies = [
"cfg-if",
]
checksum = "a7a70ba024b9dc04c27ea2f0c0548feb474ec5c54bba33a7f72f873a39d07b24"
[[package]]
name = "memchr"
version = "2.7.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "78ca9ab1a0babb1e7d5695e3530886289c18cf2f87ec19a575a0abdce112e3a3"
[[package]]
name = "multimap"
@@ -82,60 +93,129 @@ dependencies = [
[[package]]
name = "once_cell"
version = "1.17.1"
version = "1.19.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b7e5500299e16ebb147ae15a00a942af264cf3688f47923b8fc2cd5858f23ad3"
checksum = "3fdb12b2476b595f9358c5161aa467c2438859caa136dec86c26fdd2efe17b92"
[[package]]
name = "proc-macro2"
version = "1.0.86"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5e719e8df665df0d1c8fbfd238015744736151d4445ec0836b8e628aae103b77"
dependencies = [
"unicode-ident",
]
[[package]]
name = "proxy-wasm"
version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "823b744520cd4a54ba7ebacbffe4562e839d6dcd8f89209f96a1ace4f5229cd4"
version = "0.2.2"
source = "git+https://github.com/higress-group/proxy-wasm-rust-sdk?branch=main#73833051f57d483570cf5aaa9d62bd7402fae63b"
dependencies = [
"hashbrown",
"log",
]
[[package]]
name = "ryu"
version = "1.0.13"
name = "quote"
version = "1.0.36"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f91339c0467de62360649f8d3e185ca8de4224ff281f66000de5eb2a77a79041"
checksum = "0fa76aaf39101c457836aec0ce2316dbdc3ab723cdda1c6bd4e6ad4208acaca7"
dependencies = [
"proc-macro2",
]
[[package]]
name = "ryu"
version = "1.0.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f3cb5ba0dc43242ce17de99c180e96db90b235b8a9fdc9543c96d2209116bd9f"
[[package]]
name = "serde"
version = "1.0.163"
version = "1.0.207"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2113ab51b87a539ae008b5c6c02dc020ffa39afd2d83cffcb3f4eb2722cebec2"
checksum = "5665e14a49a4ea1b91029ba7d3bca9f299e1f7cfa194388ccc20f14743e784f2"
dependencies = [
"serde_derive",
]
[[package]]
name = "serde_derive"
version = "1.0.207"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6aea2634c86b0e8ef2cfdc0c340baede54ec27b1e46febd7f80dffb2aa44a00e"
dependencies = [
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "serde_json"
version = "1.0.96"
version = "1.0.125"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "057d394a50403bcac12672b2b18fb387ab6d289d957dab67dd201875391e52f1"
checksum = "83c8e735a073ccf5be70aa8066aa984eaf2fa000db6c8d0100ae605b366d31ed"
dependencies = [
"itoa",
"memchr",
"ryu",
"serde",
]
[[package]]
name = "uuid"
version = "1.3.3"
name = "syn"
version = "2.0.74"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "345444e32442451b267fc254ae85a209c64be56d2890e601a0c37ff0c3c5ecd2"
checksum = "1fceb41e3d546d0bd83421d3409b1460cc7444cd389341a4c880fe7a042cb3d7"
dependencies = [
"proc-macro2",
"quote",
"unicode-ident",
]
[[package]]
name = "unicode-ident"
version = "1.0.12"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3354b9ac3fae1ff6755cb6db53683adb661634f67557942dea4facebec0fee4b"
[[package]]
name = "uuid"
version = "1.10.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "81dfa00651efa65069b0b6b651f4aaa31ba9e3c3ce0137aaad053604ee7e0314"
dependencies = [
"getrandom",
]
[[package]]
name = "version_check"
version = "0.9.4"
version = "0.9.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "49874b5167b65d7193b8aba1567f5c7d93d001cafc34600cee003eda787e483f"
checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a"
[[package]]
name = "wasi"
version = "0.11.0+wasi-snapshot-preview1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9c8d87e72b64a3b4db28d11ce29237c246188f4f51057d65a7eab63b7987e423"
[[package]]
name = "zerocopy"
version = "0.7.35"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1b9b4fd18abc82b8136838da5d50bae7bdea537c574d8dc1a34ed098d6c166f0"
dependencies = [
"zerocopy-derive",
]
[[package]]
name = "zerocopy-derive"
version = "0.7.35"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fa4f8080344d4671fb4e831a13ad1e68092748387dfc4f55e356242fae12ce3e"
dependencies = [
"proc-macro2",
"quote",
"syn",
]

View File

@@ -6,7 +6,7 @@ edition = "2021"
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
[dependencies]
proxy-wasm = "0.2.1"
proxy-wasm = { git="https://github.com/higress-group/proxy-wasm-rust-sdk", branch="main", version="0.2.2" }
serde = "1.0"
serde_json = "1.0"
uuid = { version = "1.3.3", features = ["v4"] }

View File

@@ -1,5 +1,6 @@
FROM rust:1.69 as builder
FROM rust:1.80 as builder
WORKDIR /workspace
RUN apt update && apt-get install gcc gcc-multilib llvm clang -y && apt clean
RUN rustup target add wasm32-wasi
ARG PLUGIN_NAME="say-hello"
ARG BUILD_OPTS="--release"
@@ -9,4 +10,4 @@ RUN cargo build --target wasm32-wasi $BUILD_OPTS \
&& cp target/wasm32-wasi/release/*.wasm /main.wasm
FROM scratch
COPY --from=builder /main.wasm plugin.wasm
COPY --from=builder /main.wasm plugin.wasm

View File

@@ -6,6 +6,12 @@ IMAGE_TAG = $(if $(strip $(PLUGIN_VERSION)),${PLUGIN_VERSION},${BUILD_TIME}-${CO
IMG ?= ${REGISTRY}${PLUGIN_NAME}:${IMAGE_TAG}
.DEFAULT:
lint-base:
cargo fmt --all --check
cargo clippy --workspace --all-features --all-targets
lint:
cargo fmt --all --check --manifest-path extensions/${PLUGIN_NAME}/Cargo.toml
cargo clippy --workspace --all-features --all-targets --manifest-path extensions/${PLUGIN_NAME}/Cargo.toml
build:
DOCKER_BUILDKIT=1 docker build \
--build-arg PLUGIN_NAME=${PLUGIN_NAME} \
@@ -13,4 +19,4 @@ build:
--output extensions/${PLUGIN_NAME} \
.
@echo ""
@echo "output wasm file: extensions/${PLUGIN_NAME}/plugin.wasm"
@echo "output wasm file: extensions/${PLUGIN_NAME}/plugin.wasm"

View File

@@ -0,0 +1,851 @@
# This file is automatically @generated by Cargo.
# It is not intended for manual editing.
version = 3
[[package]]
name = "ahash"
version = "0.8.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e89da841a80418a9b391ebaea17f5c112ffaaa96f621d2c285b5174da76b9011"
dependencies = [
"cfg-if",
"once_cell",
"version_check",
"zerocopy",
]
[[package]]
name = "aho-corasick"
version = "1.1.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8e60d3430d3a69478ad0993f19238d2df97c507009a52b3c10addcd7f6bcb916"
dependencies = [
"memchr",
]
[[package]]
name = "ai-data-masking"
version = "0.1.0"
dependencies = [
"fancy-regex",
"grok",
"higress-wasm-rust",
"jieba-rs",
"jsonpath-rust",
"lazy_static",
"md5",
"proxy-wasm",
"rust-embed",
"serde",
"serde_json",
]
[[package]]
name = "allocator-api2"
version = "0.2.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5c6cb57a04249c6480766f7f7cef5467412af1490f8d1e243141daddada3264f"
[[package]]
name = "bit-set"
version = "0.5.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0700ddab506f33b20a03b13996eccd309a48e5ff77d0d95926aa0210fb4e95f1"
dependencies = [
"bit-vec",
]
[[package]]
name = "bit-vec"
version = "0.6.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "349f9b6a179ed607305526ca489b34ad0a41aed5f7980fa90eb03160b69598fb"
[[package]]
name = "bitflags"
version = "1.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a"
[[package]]
name = "block-buffer"
version = "0.10.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71"
dependencies = [
"generic-array",
]
[[package]]
name = "byteorder"
version = "1.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b"
[[package]]
name = "cc"
version = "1.1.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5fb8dd288a69fc53a1996d7ecfbf4a20d59065bff137ce7e56bbd620de191189"
dependencies = [
"shlex",
]
[[package]]
name = "cedarwood"
version = "0.4.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6d910bedd62c24733263d0bed247460853c9d22e8956bd4cd964302095e04e90"
dependencies = [
"smallvec",
]
[[package]]
name = "cfg-if"
version = "1.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "baf1de4339761588bc0619e3cbc0120ee582ebb74b53b4efbf79117bd2da40fd"
[[package]]
name = "cpufeatures"
version = "0.2.13"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "51e852e6dc9a5bed1fae92dd2375037bf2b768725bf3be87811edee3249d09ad"
dependencies = [
"libc",
]
[[package]]
name = "crypto-common"
version = "0.1.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1bfb12502f3fc46cca1bb51ac28df9d618d813cdc3d2f25b9fe775a34af26bb3"
dependencies = [
"generic-array",
"typenum",
]
[[package]]
name = "darling"
version = "0.20.10"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6f63b86c8a8826a49b8c21f08a2d07338eec8d900540f8630dc76284be802989"
dependencies = [
"darling_core",
"darling_macro",
]
[[package]]
name = "darling_core"
version = "0.20.10"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "95133861a8032aaea082871032f5815eb9e98cef03fa916ab4500513994df9e5"
dependencies = [
"fnv",
"ident_case",
"proc-macro2",
"quote",
"strsim",
"syn",
]
[[package]]
name = "darling_macro"
version = "0.20.10"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d336a2a514f6ccccaa3e09b02d41d35330c07ddf03a62165fcec10bb561c7806"
dependencies = [
"darling_core",
"quote",
"syn",
]
[[package]]
name = "derive_builder"
version = "0.20.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0350b5cb0331628a5916d6c5c0b72e97393b8b6b03b47a9284f4e7f5a405ffd7"
dependencies = [
"derive_builder_macro",
]
[[package]]
name = "derive_builder_core"
version = "0.20.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d48cda787f839151732d396ac69e3473923d54312c070ee21e9effcaa8ca0b1d"
dependencies = [
"darling",
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "derive_builder_macro"
version = "0.20.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "206868b8242f27cecce124c19fd88157fbd0dd334df2587f36417bafbc85097b"
dependencies = [
"derive_builder_core",
"syn",
]
[[package]]
name = "digest"
version = "0.10.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292"
dependencies = [
"block-buffer",
"crypto-common",
]
[[package]]
name = "fancy-regex"
version = "0.13.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "531e46835a22af56d1e3b66f04844bed63158bc094a628bec1d321d9b4c44bf2"
dependencies = [
"bit-set",
"regex-automata",
"regex-syntax",
]
[[package]]
name = "fnv"
version = "1.0.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1"
[[package]]
name = "fxhash"
version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c31b6d751ae2c7f11320402d34e41349dd1016f8d5d45e48c4312bc8625af50c"
dependencies = [
"byteorder",
]
[[package]]
name = "generic-array"
version = "0.14.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a"
dependencies = [
"typenum",
"version_check",
]
[[package]]
name = "getrandom"
version = "0.2.15"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c4567c8db10ae91089c99af84c68c38da3ec2f087c3f82960bcdbf3656b6f4d7"
dependencies = [
"cfg-if",
"libc",
"wasi",
]
[[package]]
name = "glob"
version = "0.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d2fabcfbdc87f4758337ca535fb41a6d701b65693ce38287d856d1674551ec9b"
[[package]]
name = "grok"
version = "2.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "273797968160270573071022613fc4aa28b91fe68f3eef6c96a1b2a1947ddfbd"
dependencies = [
"glob",
"onig",
]
[[package]]
name = "hashbrown"
version = "0.14.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1"
dependencies = [
"ahash",
"allocator-api2",
]
[[package]]
name = "higress-wasm-rust"
version = "0.1.0"
dependencies = [
"multimap",
"proxy-wasm",
"serde",
"serde_json",
"uuid",
]
[[package]]
name = "ident_case"
version = "1.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39"
[[package]]
name = "itoa"
version = "1.0.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "49f1f14873335454500d59611f1cf4a4b0f786f9ac11f4312a78e4cf2566695b"
[[package]]
name = "jieba-rs"
version = "0.7.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c1e2b0210dc78b49337af9e49d7ae41a39dceac6e5985613f1cf7763e2f76a25"
dependencies = [
"cedarwood",
"derive_builder",
"fxhash",
"lazy_static",
"phf",
"phf_codegen",
"regex",
]
[[package]]
name = "jsonpath-rust"
version = "0.7.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d64f9886fc067a709ab27faf63b7d3f4d1ec570a700705408b0b0683e2f43897"
dependencies = [
"pest",
"pest_derive",
"regex",
"serde_json",
"thiserror",
]
[[package]]
name = "lazy_static"
version = "1.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe"
[[package]]
name = "libc"
version = "0.2.155"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "97b3888a4aecf77e811145cadf6eef5901f4782c53886191b2f693f24761847c"
[[package]]
name = "log"
version = "0.4.22"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a7a70ba024b9dc04c27ea2f0c0548feb474ec5c54bba33a7f72f873a39d07b24"
[[package]]
name = "md5"
version = "0.7.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "490cc448043f947bae3cbee9c203358d62dbee0db12107a74be5c30ccfd09771"
[[package]]
name = "memchr"
version = "2.7.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "78ca9ab1a0babb1e7d5695e3530886289c18cf2f87ec19a575a0abdce112e3a3"
[[package]]
name = "multimap"
version = "0.10.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "defc4c55412d89136f966bbb339008b474350e5e6e78d2714439c386b3137a03"
dependencies = [
"serde",
]
[[package]]
name = "once_cell"
version = "1.19.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3fdb12b2476b595f9358c5161aa467c2438859caa136dec86c26fdd2efe17b92"
[[package]]
name = "onig"
version = "6.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8c4b31c8722ad9171c6d77d3557db078cab2bd50afcc9d09c8b315c59df8ca4f"
dependencies = [
"bitflags",
"libc",
"once_cell",
"onig_sys",
]
[[package]]
name = "onig_sys"
version = "69.8.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7b829e3d7e9cc74c7e315ee8edb185bf4190da5acde74afd7fc59c35b1f086e7"
dependencies = [
"cc",
"pkg-config",
]
[[package]]
name = "pest"
version = "2.7.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cd53dff83f26735fdc1ca837098ccf133605d794cdae66acfc2bfac3ec809d95"
dependencies = [
"memchr",
"thiserror",
"ucd-trie",
]
[[package]]
name = "pest_derive"
version = "2.7.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2a548d2beca6773b1c244554d36fcf8548a8a58e74156968211567250e48e49a"
dependencies = [
"pest",
"pest_generator",
]
[[package]]
name = "pest_generator"
version = "2.7.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3c93a82e8d145725dcbaf44e5ea887c8a869efdcc28706df2d08c69e17077183"
dependencies = [
"pest",
"pest_meta",
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "pest_meta"
version = "2.7.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a941429fea7e08bedec25e4f6785b6ffaacc6b755da98df5ef3e7dcf4a124c4f"
dependencies = [
"once_cell",
"pest",
"sha2",
]
[[package]]
name = "phf"
version = "0.11.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ade2d8b8f33c7333b51bcf0428d37e217e9f32192ae4772156f65063b8ce03dc"
dependencies = [
"phf_shared",
]
[[package]]
name = "phf_codegen"
version = "0.11.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e8d39688d359e6b34654d328e262234662d16cc0f60ec8dcbe5e718709342a5a"
dependencies = [
"phf_generator",
"phf_shared",
]
[[package]]
name = "phf_generator"
version = "0.11.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "48e4cc64c2ad9ebe670cb8fd69dd50ae301650392e81c05f9bfcb2d5bdbc24b0"
dependencies = [
"phf_shared",
"rand",
]
[[package]]
name = "phf_shared"
version = "0.11.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "90fcb95eef784c2ac79119d1dd819e162b5da872ce6f3c3abe1e8ca1c082f72b"
dependencies = [
"siphasher",
]
[[package]]
name = "pkg-config"
version = "0.3.30"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d231b230927b5e4ad203db57bbcbee2802f6bce620b1e4a9024a07d94e2907ec"
[[package]]
name = "proc-macro2"
version = "1.0.86"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5e719e8df665df0d1c8fbfd238015744736151d4445ec0836b8e628aae103b77"
dependencies = [
"unicode-ident",
]
[[package]]
name = "proxy-wasm"
version = "0.2.2"
source = "git+https://github.com/higress-group/proxy-wasm-rust-sdk?branch=main#73833051f57d483570cf5aaa9d62bd7402fae63b"
dependencies = [
"hashbrown",
"log",
]
[[package]]
name = "quote"
version = "1.0.36"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0fa76aaf39101c457836aec0ce2316dbdc3ab723cdda1c6bd4e6ad4208acaca7"
dependencies = [
"proc-macro2",
]
[[package]]
name = "rand"
version = "0.8.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "34af8d1a0e25924bc5b7c43c079c942339d8f0a8b57c39049bef581b46327404"
dependencies = [
"rand_core",
]
[[package]]
name = "rand_core"
version = "0.6.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c"
[[package]]
name = "regex"
version = "1.10.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4219d74c6b67a3654a9fbebc4b419e22126d13d2f3c4a07ee0cb61ff79a79619"
dependencies = [
"aho-corasick",
"memchr",
"regex-automata",
"regex-syntax",
]
[[package]]
name = "regex-automata"
version = "0.4.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "38caf58cc5ef2fed281f89292ef23f6365465ed9a41b7a7754eb4e26496c92df"
dependencies = [
"aho-corasick",
"memchr",
"regex-syntax",
]
[[package]]
name = "regex-syntax"
version = "0.8.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7a66a03ae7c801facd77a29370b4faec201768915ac14a721ba36f20bc9c209b"
[[package]]
name = "rust-embed"
version = "8.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fa66af4a4fdd5e7ebc276f115e895611a34739a9c1c01028383d612d550953c0"
dependencies = [
"rust-embed-impl",
"rust-embed-utils",
"walkdir",
]
[[package]]
name = "rust-embed-impl"
version = "8.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6125dbc8867951125eec87294137f4e9c2c96566e61bf72c45095a7c77761478"
dependencies = [
"proc-macro2",
"quote",
"rust-embed-utils",
"syn",
"walkdir",
]
[[package]]
name = "rust-embed-utils"
version = "8.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2e5347777e9aacb56039b0e1f28785929a8a3b709e87482e7442c72e7c12529d"
dependencies = [
"sha2",
"walkdir",
]
[[package]]
name = "ryu"
version = "1.0.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f3cb5ba0dc43242ce17de99c180e96db90b235b8a9fdc9543c96d2209116bd9f"
[[package]]
name = "same-file"
version = "1.0.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502"
dependencies = [
"winapi-util",
]
[[package]]
name = "serde"
version = "1.0.207"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5665e14a49a4ea1b91029ba7d3bca9f299e1f7cfa194388ccc20f14743e784f2"
dependencies = [
"serde_derive",
]
[[package]]
name = "serde_derive"
version = "1.0.207"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6aea2634c86b0e8ef2cfdc0c340baede54ec27b1e46febd7f80dffb2aa44a00e"
dependencies = [
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "serde_json"
version = "1.0.125"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "83c8e735a073ccf5be70aa8066aa984eaf2fa000db6c8d0100ae605b366d31ed"
dependencies = [
"itoa",
"memchr",
"ryu",
"serde",
]
[[package]]
name = "sha2"
version = "0.10.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "793db75ad2bcafc3ffa7c68b215fee268f537982cd901d132f89c6343f3a3dc8"
dependencies = [
"cfg-if",
"cpufeatures",
"digest",
]
[[package]]
name = "shlex"
version = "1.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64"
[[package]]
name = "siphasher"
version = "0.3.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "38b58827f4464d87d377d175e90bf58eb00fd8716ff0a62f80356b5e61555d0d"
[[package]]
name = "smallvec"
version = "1.13.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3c5e1a9a646d36c3599cd173a41282daf47c44583ad367b8e6837255952e5c67"
[[package]]
name = "strsim"
version = "0.11.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f"
[[package]]
name = "syn"
version = "2.0.74"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1fceb41e3d546d0bd83421d3409b1460cc7444cd389341a4c880fe7a042cb3d7"
dependencies = [
"proc-macro2",
"quote",
"unicode-ident",
]
[[package]]
name = "thiserror"
version = "1.0.63"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c0342370b38b6a11b6cc11d6a805569958d54cfa061a29969c3b5ce2ea405724"
dependencies = [
"thiserror-impl",
]
[[package]]
name = "thiserror-impl"
version = "1.0.63"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a4558b58466b9ad7ca0f102865eccc95938dca1a74a856f2b57b6629050da261"
dependencies = [
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "typenum"
version = "1.17.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "42ff0bf0c66b8238c6f3b578df37d0b7848e55df8577b3f74f92a69acceeb825"
[[package]]
name = "ucd-trie"
version = "0.1.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ed646292ffc8188ef8ea4d1e0e0150fb15a5c2e12ad9b8fc191ae7a8a7f3c4b9"
[[package]]
name = "unicode-ident"
version = "1.0.12"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3354b9ac3fae1ff6755cb6db53683adb661634f67557942dea4facebec0fee4b"
[[package]]
name = "uuid"
version = "1.10.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "81dfa00651efa65069b0b6b651f4aaa31ba9e3c3ce0137aaad053604ee7e0314"
dependencies = [
"getrandom",
]
[[package]]
name = "version_check"
version = "0.9.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a"
[[package]]
name = "walkdir"
version = "2.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b"
dependencies = [
"same-file",
"winapi-util",
]
[[package]]
name = "wasi"
version = "0.11.0+wasi-snapshot-preview1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9c8d87e72b64a3b4db28d11ce29237c246188f4f51057d65a7eab63b7987e423"
[[package]]
name = "winapi-util"
version = "0.1.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cf221c93e13a30d793f7645a0e7762c55d169dbb0a49671918a2319d289b10bb"
dependencies = [
"windows-sys",
]
[[package]]
name = "windows-sys"
version = "0.59.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b"
dependencies = [
"windows-targets",
]
[[package]]
name = "windows-targets"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973"
dependencies = [
"windows_aarch64_gnullvm",
"windows_aarch64_msvc",
"windows_i686_gnu",
"windows_i686_gnullvm",
"windows_i686_msvc",
"windows_x86_64_gnu",
"windows_x86_64_gnullvm",
"windows_x86_64_msvc",
]
[[package]]
name = "windows_aarch64_gnullvm"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3"
[[package]]
name = "windows_aarch64_msvc"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469"
[[package]]
name = "windows_i686_gnu"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b"
[[package]]
name = "windows_i686_gnullvm"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66"
[[package]]
name = "windows_i686_msvc"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66"
[[package]]
name = "windows_x86_64_gnu"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78"
[[package]]
name = "windows_x86_64_gnullvm"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d"
[[package]]
name = "windows_x86_64_msvc"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec"
[[package]]
name = "zerocopy"
version = "0.7.35"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1b9b4fd18abc82b8136838da5d50bae7bdea537c574d8dc1a34ed098d6c166f0"
dependencies = [
"zerocopy-derive",
]
[[package]]
name = "zerocopy-derive"
version = "0.7.35"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fa4f8080344d4671fb4e831a13ad1e68092748387dfc4f55e356242fae12ce3e"
dependencies = [
"proc-macro2",
"quote",
"syn",
]

View File

@@ -0,0 +1,22 @@
[package]
name = "ai-data-masking"
version = "0.1.0"
edition = "2021"
publish = false
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
[lib]
crate-type = ["cdylib"]
[dependencies]
higress-wasm-rust = { path = "../../", version = "0.1.0" }
proxy-wasm = { git="https://github.com/higress-group/proxy-wasm-rust-sdk", branch="main", version="0.2.2" }
serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0"
fancy-regex = "0"
md5 = "0"
grok = "2"
lazy_static = "1"
jieba-rs = "0"
rust-embed="8.5.0"
jsonpath-rust = "0"

View File

@@ -0,0 +1,155 @@
# 功能说明
对请求/返回中的敏感词拦截、替换
```mermaid
sequenceDiagram
participant 用户
participant 敏感词插件
participant 后端服务
用户->>敏感词插件: 请求数据(如:包含admin@gmail.com)
敏感词插件->>敏感词插件: 数据解析
opt 如果包含拦截词
敏感词插件-->>用户: 返回预设错误消息 (拦截)
end
opt 替换敏感词
敏感词插件->>后端服务: 关键词替换后的请求数据 (将admin@gmail.com替换为****@gmail.com)
后端服务->>敏感词插件: 原始返回响应(包含 ****@gmail.com)
敏感词插件->>用户: 数据恢复后的相应数据(将****@gmail.com恢复为admin@gmail.com)
end
```
## 处理数据范围
- openai协议请求/返回对话内容
- jsonpath只处理指定字段
- raw整个请求/返回body
## 敏感词拦截
- 处理数据范围中出现敏感词直接拦截,返回预设错误信息
- 支持系统内置敏感词库和自定义敏感词
## 敏感词替换
- 将请求数据中出现的敏感词替换为脱敏字符串,传递给后端服务。可保证敏感数据不出域
- 部分脱敏数据在后端服务返回后可进行还原
- 自定义规则支持标准正则和grok规则替换字符串支持变量替换
# 配置字段
| 名称 | 数据类型 | 默认值 | 描述 |
| -------- | -------- | -------- | -------- |
| deny_openai | bool | true | 对openai协议进行拦截 |
| deny_jsonpath | string | [] | 对指定jsonpath拦截 |
| deny_raw | bool | false | 对原始body拦截 |
| system_deny | bool | true | 开启内置拦截规则 |
| deny_code | int | 200 | 拦截时http状态码 |
| deny_message | string | 提问或回答中包含敏感词,已被屏蔽 | 拦截时ai返回消息 |
| deny_raw_message | string | {"errmsg":"提问或回答中包含敏感词,已被屏蔽"} | 非openai拦截时返回内容 |
| deny_content_type | string | application/json | 非openai拦截时返回content_type头 |
| deny_words | array of string | [] | 自定义敏感词列表 |
| replace_roles | array | - | 自定义敏感词正则替换 |
| replace_roles.regex | string | - | 规则正则(内置GROK规则) |
| replace_roles.type | [replace, hash] | - | 替换类型 |
| replace_roles.restore | bool | false | 是否恢复 |
| replace_roles.value | string | - | 替换值(支持正则变量) |
# 配置示例
```yaml
system_deny: true
deny_openai: true
deny_jsonpath:
- "$.messages[*].content"
deny_raw: true
deny_code: 200
deny_message: "提问或回答中包含敏感词,已被屏蔽"
deny_raw_message: "{\"errmsg\":\"提问或回答中包含敏感词,已被屏蔽\"}"
deny_content_type: "application/json"
deny_words:
- "自定义敏感词1"
- "自定义敏感词2"
replace_roles:
- regex: "%{MOBILE}"
type: "replace"
value: "****"
# 手机号 13800138000 -> ****
- regex: "%{EMAILLOCALPART}@%{HOSTNAME:domain}"
type: "replace"
restore: true
value: "****@$domain"
# 电子邮箱 admin@gmail.com -> ****@gmail.com
- regex: "%{IP}"
type: "replace"
restore: true
value: "***.***.***.***"
# ip 192.168.0.1 -> ***.***.***.***
- regex: "%{IDCARD}"
type: "replace"
value: "****"
# 身份证号 110000000000000000 -> ****
- regex: "sk-[0-9a-zA-Z]*"
restore: true
type: "hash"
# hash sk-12345 -> 9cb495455da32f41567dab1d07f1973d
# hash后的值提供给大模型从大模型返回的数据中会将hash值还原为原始值
```
# 敏感词替换样例
## 用户请求内容
请将 `curl http://172.20.5.14/api/openai/v1/chat/completions -H "Authorization: sk-12345" -H "Auth: test@gmail.com"` 改成post方式
## 处理后请求大模型内容
`curl http://***.***.***.***/api/openai/v1/chat/completions -H "Authorization: 48a7e98a91d93896d8dac522c5853948" -H "Auth: ****@gmail.com"` 改成post方式
## 大模型返回内容
您想要将一个 `curl` 的 GET 请求转换为 POST 请求,并且这个请求是向一个特定的 API 发送数据。下面是修改后的 `curl` 命令,以 POST 方式发送:
```sh
curl -X POST \
-H "Authorization: 48a7e98a91d93896d8dac522c5853948" \
-H "Auth: ****@gmail.com" \
-H "Content-Type: application/json" \
-d '{"key":"value"}' \
http://***.***.***.***/api/openai/v1/chat/completions
```
这里做了如下几个修改:
- `-X POST` 设置请求方式为 POST。
- `-H "Content-Type: application/json"` 设置请求头中的 `Content-Type``application/json`,这通常用来告诉服务器您发送的数据格式是 JSON。
- `-d '{"key":"value"}'` 这里设置了要发送的数据,`'{"key":"value"}'` 是一个简单的 JSON 对象示例。您需要将其替换为您实际想要发送的数据。
请注意,您需要将 `"key":"value"` 替换为您实际要发送的数据内容。如果您的 API 接受不同的数据结构或者需要特定的字段,请根据实际情况调整这部分内容。
## 处理后返回用户内容
您想要将一个 `curl` 的 GET 请求转换为 POST 请求,并且这个请求是向一个特定的 API 发送数据。下面是修改后的 `curl` 命令,以 POST 方式发送:
```sh
curl -X POST \
-H "Authorization: sk-12345" \
-H "Auth: test@gmail.com" \
-H "Content-Type: application/json" \
-d '{"key":"value"}' \
http://172.20.5.14/api/openai/v1/chat/completions
```
这里做了如下几个修改:
- `-X POST` 设置请求方式为 POST。
- `-H "Content-Type: application/json"` 设置请求头中的 `Content-Type``application/json`,这通常用来告诉服务器您发送的数据格式是 JSON。
- `-d '{"key":"value"}'` 这里设置了要发送的数据,`'{"key":"value"}'` 是一个简单的 JSON 对象示例。您需要将其替换为您实际想要发送的数据。
请注意,您需要将 `"key":"value"` 替换为您实际要发送的数据内容。如果您的 API 接受不同的数据结构或者需要特定的字段,请根据实际情况调整这部分内容。
# 相关说明
- 流模式中如果脱敏后的词被多个chunk拆分可能无法进行还原
- 流模式中如果敏感词语被多个chunk拆分可能会有敏感词的一部分返回给用户的情况
- grok 内置规则列表 https://help.aliyun.com/zh/sls/user-guide/grok-patterns
- 内置拦截规则数据来源 https://github.com/houbb/sensitive-word/tree/master/src/main/resources

View File

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,732 @@
// Copyright (c) 2023 Alibaba Group Holding Ltd.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
use fancy_regex::Regex;
use grok::patterns;
use higress_wasm_rust::log::Log;
use higress_wasm_rust::plugin_wrapper::{HttpContextWrapper, RootContextWrapper};
use higress_wasm_rust::rule_matcher::{on_configure, RuleMatcher, SharedRuleMatcher};
use jieba_rs::Jieba;
use jsonpath_rust::{JsonPath, JsonPathValue};
use lazy_static::lazy_static;
use proxy_wasm::traits::{Context, HttpContext, RootContext};
use proxy_wasm::types::{Bytes, ContextType, DataAction, HeaderAction, LogLevel};
use rust_embed::Embed;
use serde::de::Error;
use serde::Deserialize;
use serde::Deserializer;
use serde_json::{json, Value};
use std::cell::RefCell;
use std::collections::{BTreeMap, HashMap, HashSet, VecDeque};
use std::ops::DerefMut;
use std::rc::Rc;
use std::str::FromStr;
use std::vec;
proxy_wasm::main! {{
proxy_wasm::set_log_level(LogLevel::Trace);
proxy_wasm::set_root_context(|_|Box::new(AiDataMaskingRoot::new()));
}}
const PLUGIN_NAME: &str = "ai-data-masking";
const GROK_PATTERN: &str = r"%\{(?<name>(?<pattern>[A-z0-9]+)(?::(?<alias>[A-z0-9_:;\/\s\.]+))?)\}";
#[derive(Embed)]
#[folder = "res/"]
struct Asset;
#[derive(Default, Debug, Clone)]
struct DenyWord {
jieba: Jieba,
words: HashSet<String>,
}
struct System {
deny_word: DenyWord,
grok_regex: Regex,
grok_patterns: BTreeMap<String, String>,
}
lazy_static! {
static ref SYSTEM: System = System::new();
}
struct AiDataMaskingRoot {
log: Log,
rule_matcher: SharedRuleMatcher<AiDataMaskingConfig>,
}
struct AiDataMasking {
config: Option<AiDataMaskingConfig>,
mask_map: HashMap<String, Option<String>>,
is_openai: bool,
stream: bool,
res_body: Bytes,
}
fn deserialize_regexp<'de, D>(deserializer: D) -> Result<Regex, D::Error>
where
D: Deserializer<'de>,
{
let value: Value = Deserialize::deserialize(deserializer)?;
if let Some(pattern) = value.as_str() {
let (p, _) = SYSTEM.grok_to_pattern(pattern);
if let Ok(reg) = Regex::new(&p) {
Ok(reg)
} else if let Ok(reg) = Regex::new(pattern) {
Ok(reg)
} else {
Err(Error::custom(format!("regexp error field {}", pattern)))
}
} else {
Err(Error::custom("regexp error not string".to_string()))
}
}
fn deserialize_type<'de, D>(deserializer: D) -> Result<Type, D::Error>
where
D: Deserializer<'de>,
{
let value: Value = Deserialize::deserialize(deserializer)?;
if let Some(_type) = value.as_str() {
if _type == "replace" {
Ok(Type::Replace)
} else if _type == "hash" {
Ok(Type::Hash)
} else {
Err(Error::custom(format!("regexp error value {}", _type)))
}
} else {
Err(Error::custom("type error not string".to_string()))
}
}
fn deserialize_denyword<'de, D>(deserializer: D) -> Result<DenyWord, D::Error>
where
D: Deserializer<'de>,
{
let value: Vec<String> = Deserialize::deserialize(deserializer)?;
Ok(DenyWord::from_iter(value))
}
fn deserialize_jsonpath<'de, D>(deserializer: D) -> Result<Vec<JsonPath>, D::Error>
where
D: Deserializer<'de>,
{
let value: Vec<String> = Deserialize::deserialize(deserializer)?;
let mut ret = Vec::new();
for v in value {
if v.is_empty() {
continue;
}
match JsonPath::from_str(&v) {
Ok(jp) => ret.push(jp),
Err(_) => return Err(Error::custom(format!("jsonpath error value {}", v))),
}
}
Ok(ret)
}
#[derive(Debug, Clone, PartialEq, Eq)]
enum Type {
Replace,
Hash,
}
#[derive(Debug, Deserialize, Clone)]
struct Rule {
#[serde(deserialize_with = "deserialize_regexp")]
regex: Regex,
#[serde(deserialize_with = "deserialize_type", alias = "type")]
type_: Type,
#[serde(default)]
restore: bool,
#[serde(default)]
value: String,
}
fn default_deny_openai() -> bool {
true
}
fn default_deny_raw() -> bool {
false
}
fn default_system_deny() -> bool {
true
}
fn default_deny_code() -> u16 {
200
}
fn default_deny_content_type() -> String {
"application/json".to_string()
}
fn default_deny_raw_message() -> String {
"{\"errmsg\":\"提问或回答中包含敏感词,已被屏蔽\"}".to_string()
}
fn default_deny_message() -> String {
"提问或回答中包含敏感词,已被屏蔽".to_string()
}
#[derive(Default, Debug, Deserialize, Clone)]
pub struct AiDataMaskingConfig {
#[serde(default = "default_deny_openai")]
deny_openai: bool,
#[serde(default = "default_deny_raw")]
deny_raw: bool,
#[serde(default, deserialize_with = "deserialize_jsonpath")]
deny_jsonpath: Vec<JsonPath>,
#[serde(default = "default_system_deny")]
system_deny: bool,
#[serde(default = "default_deny_code")]
deny_code: u16,
#[serde(default = "default_deny_message")]
deny_message: String,
#[serde(default = "default_deny_raw_message")]
deny_raw_message: String,
#[serde(default = "default_deny_content_type")]
deny_content_type: String,
#[serde(default)]
replace_roles: Vec<Rule>,
#[serde(deserialize_with = "deserialize_denyword", default = "DenyWord::empty")]
deny_words: DenyWord,
}
#[derive(Debug, Deserialize, Clone)]
struct Message {
content: String,
}
#[derive(Debug, Deserialize, Clone)]
struct Req {
#[serde(default)]
stream: bool,
messages: Vec<Message>,
}
#[derive(Default, Debug, Deserialize)]
struct ResMessage {
#[serde(default)]
message: Option<Message>,
#[serde(default)]
delta: Option<Message>,
}
#[derive(Default, Debug, Deserialize)]
struct Res {
#[serde(default)]
choices: Vec<ResMessage>,
}
static SYSTEM_PATTERNS: &[(&str, &str)] = &[
("MOBILE", r#"\d{8,11}"#),
("IDCARD", r#"\d{17}[0-9xX]|\d{15}"#),
];
impl DenyWord {
fn empty() -> Self {
DenyWord {
jieba: Jieba::empty(),
words: HashSet::new(),
}
}
fn from_iter<T: IntoIterator<Item = impl Into<String>>>(words: T) -> Self {
let mut deny_word = DenyWord::empty();
for word in words {
let _w = word.into();
let w = _w.trim();
if w.is_empty() {
continue;
}
deny_word.jieba.add_word(w, None, None);
deny_word.words.insert(w.to_string());
}
deny_word
}
fn default() -> Self {
if let Some(file) = Asset::get("sensitive_word_dict.txt") {
if let Ok(data) = std::str::from_utf8(file.data.as_ref()) {
return DenyWord::from_iter(data.split('\n'));
}
}
DenyWord::empty()
}
fn check(&self, message: &str) -> bool {
for word in self.jieba.cut(message, true) {
if self.words.contains(word) {
return true;
}
}
false
}
}
impl System {
fn new() -> Self {
let grok_regex = Regex::new(GROK_PATTERN).unwrap();
let grok_patterns = BTreeMap::new();
let mut system = System {
deny_word: DenyWord::default(),
grok_regex,
grok_patterns,
};
system.init();
system
}
fn init(&mut self) {
let mut grok_temp_patterns = VecDeque::new();
for patterns in [patterns(), SYSTEM_PATTERNS] {
for &(key, value) in patterns {
if self.grok_regex.is_match(value).is_ok_and(|r| r) {
grok_temp_patterns.push_back((String::from(key), String::from(value)));
} else {
self.grok_patterns
.insert(String::from(key), String::from(value));
}
}
}
let mut last_ok: Option<String> = None;
while let Some((key, value)) = grok_temp_patterns.pop_front() {
if let Some(k) = &last_ok {
if k == &key {
break;
}
}
let (v, ok) = self.grok_to_pattern(&value);
if ok {
self.grok_patterns.insert(key, v);
last_ok = None;
} else {
if last_ok.is_none() {
last_ok = Some(key.clone());
}
grok_temp_patterns.push_back((key, v));
}
}
}
fn grok_to_pattern(&self, pattern: &str) -> (String, bool) {
let mut ok = true;
let mut ret = pattern.to_string();
for _c in self.grok_regex.captures_iter(pattern) {
if _c.is_err() {
ok = false;
continue;
}
let c = _c.unwrap();
if let (Some(full), Some(name)) = (c.get(0), c.name("pattern")) {
if let Some(p) = self.grok_patterns.get(name.as_str()) {
if let Some(alias) = c.name("alias") {
ret = ret.replace(full.as_str(), &format!("(?P<{}>{})", alias.as_str(), p));
} else {
ret = ret.replace(full.as_str(), p);
}
} else {
ok = false;
}
}
}
(ret, ok)
}
}
impl AiDataMaskingRoot {
fn new() -> Self {
AiDataMaskingRoot {
log: Log::new(PLUGIN_NAME.to_string()),
rule_matcher: Rc::new(RefCell::new(RuleMatcher::default())),
}
}
}
impl Context for AiDataMaskingRoot {}
impl RootContext for AiDataMaskingRoot {
fn on_configure(&mut self, _plugin_configuration_size: usize) -> bool {
on_configure(
self,
_plugin_configuration_size,
self.rule_matcher.borrow_mut().deref_mut(),
&self.log,
)
}
fn create_http_context(&self, _context_id: u32) -> Option<Box<dyn HttpContext>> {
self.create_http_context_use_wrapper(_context_id)
}
fn get_type(&self) -> Option<ContextType> {
Some(ContextType::HttpContext)
}
}
impl RootContextWrapper<AiDataMaskingConfig> for AiDataMaskingRoot {
fn rule_matcher(&self) -> &SharedRuleMatcher<AiDataMaskingConfig> {
&self.rule_matcher
}
fn create_http_context_wrapper(
&self,
_context_id: u32,
) -> Option<Box<dyn HttpContextWrapper<AiDataMaskingConfig>>> {
Some(Box::new(AiDataMasking {
mask_map: HashMap::new(),
config: None,
is_openai: false,
stream: false,
res_body: Bytes::new(),
}))
}
}
impl AiDataMasking {
fn check_message(&self, message: &str) -> bool {
if let Some(config) = &self.config {
config.deny_words.check(message)
|| (config.system_deny && SYSTEM.deny_word.check(message))
} else {
false
}
}
fn msg_to_response(&self, msg: &str, raw_msg: &str, content_type: &str) -> (String, String) {
if !self.is_openai {
(raw_msg.to_string(), content_type.to_string())
} else if self.stream {
(
format!(
"data:{}\n\n",
json!({"choices": [{"index": 0, "delta": {"role": "assistant", "content": msg}}], "usage": {}})
),
"text/event-stream;charset=UTF-8".to_string(),
)
} else {
(
json!({"choices": [{"index": 0, "message": {"role": "assistant", "content": msg}}], "usage": {}}).to_string(),
"application/json".to_string()
)
}
}
fn deny(&mut self, in_response: bool) -> DataAction {
if in_response && self.stream {
self.replace_http_response_body(&[]);
return DataAction::Continue;
}
let (deny_code, (deny_message, content_type)) = if let Some(config) = &self.config {
(
config.deny_code,
self.msg_to_response(
&config.deny_message,
&config.deny_raw_message,
&config.deny_content_type,
),
)
} else {
(
default_deny_code(),
self.msg_to_response(
&default_deny_message(),
&default_deny_raw_message(),
&default_deny_content_type(),
),
)
};
if in_response {
self.replace_http_response_body(deny_message.as_bytes());
return DataAction::Continue;
}
self.send_http_response(
deny_code as u32,
vec![("Content-Type", &content_type)],
Some(deny_message.as_bytes()),
);
DataAction::StopIterationAndBuffer
}
fn process_sse_message(&mut self, sse_message: &str) -> Vec<String> {
let mut messages = Vec::new();
for msg in sse_message.split('\n') {
if !msg.starts_with("data:") {
continue;
}
let res: Res = if let Some(m) = msg.strip_prefix("data:") {
match serde_json::from_str(m) {
Ok(r) => r,
Err(_) => continue,
}
} else {
continue;
};
if res.choices.is_empty() {
continue;
}
for choice in &res.choices {
if let Some(delta) = &choice.delta {
messages.push(delta.content.clone());
}
}
}
messages
}
fn replace_request_msg(&mut self, message: &str) -> String {
let config = self.config.as_ref().unwrap();
let mut msg = message.to_string();
for rule in &config.replace_roles {
let mut replace_pair = Vec::new();
if rule.type_ == Type::Replace && !rule.restore {
msg = rule.regex.replace_all(&msg, &rule.value).to_string();
} else {
for _m in rule.regex.find_iter(&msg) {
if _m.is_err() {
continue;
}
let m = _m.unwrap();
let from_word = m.as_str();
let to_word = match rule.type_ {
Type::Hash => {
let digest = md5::compute(from_word.as_bytes());
format!("{:x}", digest)
}
Type::Replace => rule.regex.replace(from_word, &rule.value).to_string(),
};
replace_pair.push((from_word.to_string(), to_word.clone()));
if rule.restore && !to_word.is_empty() {
match self.mask_map.entry(to_word) {
std::collections::hash_map::Entry::Occupied(mut e) => {
e.insert(None);
}
std::collections::hash_map::Entry::Vacant(e) => {
e.insert(Some(from_word.to_string()));
}
}
}
}
for (from_word, to_word) in replace_pair {
msg = msg.replace(&from_word, &to_word);
}
}
}
msg
}
}
impl Context for AiDataMasking {}
impl HttpContext for AiDataMasking {
fn on_http_request_headers(
&mut self,
_num_headers: usize,
_end_of_stream: bool,
) -> HeaderAction {
HeaderAction::StopIteration
}
fn on_http_response_headers(
&mut self,
_num_headers: usize,
_end_of_stream: bool,
) -> HeaderAction {
self.set_http_response_header("Content-Length", None);
HeaderAction::Continue
}
fn on_http_response_body(&mut self, body_size: usize, _end_of_stream: bool) -> DataAction {
if !self.stream {
return DataAction::Continue;
}
if let Some(body) = self.get_http_response_body(0, body_size) {
self.res_body.extend(&body);
if let Ok(body_str) = String::from_utf8(self.res_body.clone()) {
if self.is_openai {
let messages = self.process_sse_message(&body_str);
if self.check_message(&messages.join("")) {
return self.deny(true);
}
} else if self.check_message(&body_str) {
return self.deny(true);
}
}
if self.mask_map.is_empty() {
return DataAction::Continue;
}
if let Ok(body_str) = std::str::from_utf8(&body) {
let mut new_str = body_str.to_string();
if self.is_openai {
let messages = self.process_sse_message(body_str);
for message in messages {
let mut new_message = message.clone();
for (from_word, to_word) in self.mask_map.iter() {
if let Some(to) = to_word {
new_message = new_message.replace(from_word, to);
}
}
if new_message != message {
new_str = new_str.replace(
&json!(message).to_string(),
&json!(new_message).to_string(),
);
}
}
} else {
for (from_word, to_word) in self.mask_map.iter() {
if let Some(to) = to_word {
new_str = new_str.replace(from_word, to);
}
}
}
if new_str != body_str {
self.replace_http_response_body(new_str.as_bytes());
}
}
}
DataAction::Continue
}
}
impl HttpContextWrapper<AiDataMaskingConfig> for AiDataMasking {
fn on_config(&mut self, config: &AiDataMaskingConfig) {
self.config = Some(config.clone());
}
fn cache_request_body(&self) -> bool {
true
}
fn cache_response_body(&self) -> bool {
!self.stream
}
fn on_http_request_complete_body(&mut self, req_body: &Bytes) -> DataAction {
if self.config.is_none() {
return DataAction::Continue;
}
let config = self.config.as_ref().unwrap();
let mut req_body = match String::from_utf8(req_body.clone()) {
Ok(r) => r,
Err(_) => return DataAction::Continue,
};
if config.deny_openai {
if let Ok(r) = serde_json::from_str(req_body.as_str()) {
let req: Req = r;
self.is_openai = true;
self.stream = req.stream;
for msg in req.messages {
if self.check_message(&msg.content) {
return self.deny(false);
}
let new_content = self.replace_request_msg(&msg.content);
if new_content != msg.content {
if let (Ok(from), Ok(to)) = (
serde_json::to_string(&msg.content),
serde_json::to_string(&new_content),
) {
req_body = req_body.replace(&from, &to);
}
}
}
self.replace_http_request_body(req_body.as_bytes());
return DataAction::Continue;
}
}
if !config.deny_jsonpath.is_empty() {
if let Ok(r) = serde_json::from_str(req_body.as_str()) {
let json: Value = r;
for jsonpath in config.deny_jsonpath.clone() {
for v in jsonpath.find_slice(&json) {
if let JsonPathValue::Slice(d, _) = v {
if let Some(s) = d.as_str() {
if self.check_message(s) {
return self.deny(false);
}
let content = s.to_string();
let new_content = self.replace_request_msg(&content);
if new_content != content {
if let (Ok(from), Ok(to)) = (
serde_json::to_string(&content),
serde_json::to_string(&new_content),
) {
req_body = req_body.replace(&from, &to);
}
}
}
}
}
}
self.replace_http_request_body(req_body.as_bytes());
return DataAction::Continue;
}
}
if config.deny_raw {
if self.check_message(&req_body) {
return self.deny(false);
}
let new_body = self.replace_request_msg(&req_body);
if new_body != req_body {
self.replace_http_request_body(new_body.as_bytes())
}
return DataAction::Continue;
}
DataAction::Continue
}
fn on_http_response_complete_body(&mut self, res_body: &Bytes) -> DataAction {
if self.config.is_none() {
self.reset_http_response();
return DataAction::Continue;
}
let config = self.config.as_ref().unwrap();
let mut res_body = match String::from_utf8(res_body.clone()) {
Ok(r) => r,
Err(_) => {
self.reset_http_response();
return DataAction::Continue;
}
};
if config.deny_openai && self.is_openai {
if let Ok(r) = serde_json::from_str(res_body.as_str()) {
let res: Res = r;
for msg in res.choices {
if let Some(meesage) = msg.message {
if self.check_message(&meesage.content) {
return self.deny(true);
}
if self.mask_map.is_empty() {
continue;
}
let mut m = meesage.content.clone();
for (from_word, to_word) in self.mask_map.iter() {
if let Some(to) = to_word {
m = m.replace(from_word, to);
}
}
if m != meesage.content {
if let (Ok(from), Ok(to)) = (
serde_json::to_string(&meesage.content),
serde_json::to_string(&m),
) {
res_body = res_body.replace(&from, &to);
}
}
}
}
self.replace_http_response_body(res_body.as_bytes());
return DataAction::Continue;
}
}
if config.deny_raw {
if self.check_message(&res_body) {
return self.deny(true);
}
if !self.mask_map.is_empty() {
for (from_word, to_word) in self.mask_map.iter() {
if let Some(to) = to_word {
res_body = res_body.replace(from_word, to);
}
}
}
self.replace_http_response_body(res_body.as_bytes());
return DataAction::Continue;
}
DataAction::Continue
}
}

View File

@@ -4,13 +4,14 @@ version = 3
[[package]]
name = "ahash"
version = "0.8.3"
version = "0.8.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2c99f64d1e06488f620f932677e24bc6e2897582980441ae90a671415bd7ec2f"
checksum = "e89da841a80418a9b391ebaea17f5c112ffaaa96f621d2c285b5174da76b9011"
dependencies = [
"cfg-if",
"once_cell",
"version_check",
"zerocopy",
]
[[package]]
@@ -22,6 +23,12 @@ dependencies = [
"memchr",
]
[[package]]
name = "allocator-api2"
version = "0.2.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5c6cb57a04249c6480766f7f7cef5467412af1490f8d1e243141daddada3264f"
[[package]]
name = "cfg-if"
version = "1.0.0"
@@ -30,9 +37,9 @@ checksum = "baf1de4339761588bc0619e3cbc0120ee582ebb74b53b4efbf79117bd2da40fd"
[[package]]
name = "getrandom"
version = "0.2.9"
version = "0.2.15"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c85e1d9ab2eadba7e5040d4e09cbd6d072b76a557ad64e797c2cb9d4da21d7e4"
checksum = "c4567c8db10ae91089c99af84c68c38da3ec2f087c3f82960bcdbf3656b6f4d7"
dependencies = [
"cfg-if",
"libc",
@@ -41,11 +48,12 @@ dependencies = [
[[package]]
name = "hashbrown"
version = "0.13.2"
version = "0.14.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "43a3c133739dddd0d2990f9a4bdf8eb4b21ef50e4851ca85ab661199821d510e"
checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1"
dependencies = [
"ahash",
"allocator-api2",
]
[[package]]
@@ -61,21 +69,21 @@ dependencies = [
[[package]]
name = "itoa"
version = "1.0.6"
version = "1.0.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "453ad9f582a441959e5f0d088b02ce04cfe8d51a8eaf077f12ac6d3e94164ca6"
checksum = "49f1f14873335454500d59611f1cf4a4b0f786f9ac11f4312a78e4cf2566695b"
[[package]]
name = "libc"
version = "0.2.144"
version = "0.2.155"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2b00cc1c228a6782d0f076e7b232802e0c5689d41bb5df366f2a6b6621cfdfe1"
checksum = "97b3888a4aecf77e811145cadf6eef5901f4782c53886191b2f693f24761847c"
[[package]]
name = "log"
version = "0.4.18"
version = "0.4.22"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "518ef76f2f87365916b142844c16d8fefd85039bc5699050210a7778ee1cd1de"
checksum = "a7a70ba024b9dc04c27ea2f0c0548feb474ec5c54bba33a7f72f873a39d07b24"
[[package]]
name = "memchr"
@@ -94,24 +102,23 @@ dependencies = [
[[package]]
name = "once_cell"
version = "1.17.2"
version = "1.19.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9670a07f94779e00908f3e686eab508878ebb390ba6e604d3a284c00e8d0487b"
checksum = "3fdb12b2476b595f9358c5161aa467c2438859caa136dec86c26fdd2efe17b92"
[[package]]
name = "proc-macro2"
version = "1.0.59"
version = "1.0.86"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6aeca18b86b413c660b781aa319e4e2648a3e6f9eadc9b47e9038e6fe9f3451b"
checksum = "5e719e8df665df0d1c8fbfd238015744736151d4445ec0836b8e628aae103b77"
dependencies = [
"unicode-ident",
]
[[package]]
name = "proxy-wasm"
version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "823b744520cd4a54ba7ebacbffe4562e839d6dcd8f89209f96a1ace4f5229cd4"
version = "0.2.2"
source = "git+https://github.com/higress-group/proxy-wasm-rust-sdk?branch=main#73833051f57d483570cf5aaa9d62bd7402fae63b"
dependencies = [
"hashbrown",
"log",
@@ -119,18 +126,18 @@ dependencies = [
[[package]]
name = "quote"
version = "1.0.28"
version = "1.0.36"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1b9ab9c7eadfd8df19006f1cf1a4aed13540ed5cbc047010ece5826e10825488"
checksum = "0fa76aaf39101c457836aec0ce2316dbdc3ab723cdda1c6bd4e6ad4208acaca7"
dependencies = [
"proc-macro2",
]
[[package]]
name = "regex"
version = "1.10.5"
version = "1.10.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b91213439dad192326a0d7c6ee3955910425f441d7038e0d6933b0aec5c4517f"
checksum = "4219d74c6b67a3654a9fbebc4b419e22126d13d2f3c4a07ee0cb61ff79a79619"
dependencies = [
"aho-corasick",
"memchr",
@@ -169,24 +176,24 @@ dependencies = [
[[package]]
name = "ryu"
version = "1.0.13"
version = "1.0.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f91339c0467de62360649f8d3e185ca8de4224ff281f66000de5eb2a77a79041"
checksum = "f3cb5ba0dc43242ce17de99c180e96db90b235b8a9fdc9543c96d2209116bd9f"
[[package]]
name = "serde"
version = "1.0.163"
version = "1.0.207"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2113ab51b87a539ae008b5c6c02dc020ffa39afd2d83cffcb3f4eb2722cebec2"
checksum = "5665e14a49a4ea1b91029ba7d3bca9f299e1f7cfa194388ccc20f14743e784f2"
dependencies = [
"serde_derive",
]
[[package]]
name = "serde_derive"
version = "1.0.163"
version = "1.0.207"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8c805777e3930c8883389c602315a24224bcc738b63905ef87cd1420353ea93e"
checksum = "6aea2634c86b0e8ef2cfdc0c340baede54ec27b1e46febd7f80dffb2aa44a00e"
dependencies = [
"proc-macro2",
"quote",
@@ -195,20 +202,21 @@ dependencies = [
[[package]]
name = "serde_json"
version = "1.0.96"
version = "1.0.125"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "057d394a50403bcac12672b2b18fb387ab6d289d957dab67dd201875391e52f1"
checksum = "83c8e735a073ccf5be70aa8066aa984eaf2fa000db6c8d0100ae605b366d31ed"
dependencies = [
"itoa",
"memchr",
"ryu",
"serde",
]
[[package]]
name = "syn"
version = "2.0.18"
version = "2.0.74"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "32d41677bcbe24c20c52e7c70b0d8db04134c5d1066bf98662e2871ad200ea3e"
checksum = "1fceb41e3d546d0bd83421d3409b1460cc7444cd389341a4c880fe7a042cb3d7"
dependencies = [
"proc-macro2",
"quote",
@@ -217,27 +225,47 @@ dependencies = [
[[package]]
name = "unicode-ident"
version = "1.0.9"
version = "1.0.12"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b15811caf2415fb889178633e7724bad2509101cde276048e013b9def5e51fa0"
checksum = "3354b9ac3fae1ff6755cb6db53683adb661634f67557942dea4facebec0fee4b"
[[package]]
name = "uuid"
version = "1.3.3"
version = "1.10.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "345444e32442451b267fc254ae85a209c64be56d2890e601a0c37ff0c3c5ecd2"
checksum = "81dfa00651efa65069b0b6b651f4aaa31ba9e3c3ce0137aaad053604ee7e0314"
dependencies = [
"getrandom",
]
[[package]]
name = "version_check"
version = "0.9.4"
version = "0.9.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "49874b5167b65d7193b8aba1567f5c7d93d001cafc34600cee003eda787e483f"
checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a"
[[package]]
name = "wasi"
version = "0.11.0+wasi-snapshot-preview1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9c8d87e72b64a3b4db28d11ce29237c246188f4f51057d65a7eab63b7987e423"
[[package]]
name = "zerocopy"
version = "0.7.35"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1b9b4fd18abc82b8136838da5d50bae7bdea537c574d8dc1a34ed098d6c166f0"
dependencies = [
"zerocopy-derive",
]
[[package]]
name = "zerocopy-derive"
version = "0.7.35"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fa4f8080344d4671fb4e831a13ad1e68092748387dfc4f55e356242fae12ce3e"
dependencies = [
"proc-macro2",
"quote",
"syn",
]

View File

@@ -10,8 +10,8 @@ crate-type = ["cdylib"]
[dependencies]
higress-wasm-rust = { path = "../../", version = "0.1.0" }
proxy-wasm = "0.2.1"
proxy-wasm = { git="https://github.com/higress-group/proxy-wasm-rust-sdk", branch="main", version="0.2.2" }
serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0"
regex = "1"
multimap = "0"
multimap = "0"

View File

@@ -17,7 +17,7 @@ use higress_wasm_rust::plugin_wrapper::{HttpContextWrapper, RootContextWrapper};
use higress_wasm_rust::rule_matcher::{on_configure, RuleMatcher, SharedRuleMatcher};
use multimap::MultiMap;
use proxy_wasm::traits::{Context, HttpContext, RootContext};
use proxy_wasm::types::{Action, Bytes, ContextType, LogLevel};
use proxy_wasm::types::{Bytes, ContextType, DataAction, HeaderAction, LogLevel};
use regex::Regex;
use serde::de::Error;
use serde::Deserialize;
@@ -148,9 +148,12 @@ impl HttpContextWrapper<RquestBlockConfig> for RquestBlock {
fn cache_request_body(&self) -> bool {
self.cache_request
}
fn on_http_request_headers_ok(&mut self, headers: &MultiMap<String, String>) -> Action {
fn on_http_request_complete_headers(
&mut self,
headers: &MultiMap<String, String>,
) -> HeaderAction {
if self.config.is_none() {
return Action::Continue;
return HeaderAction::Continue;
}
let config = self.config.as_ref().unwrap();
if !config.block_urls.is_empty()
@@ -161,7 +164,7 @@ impl HttpContextWrapper<RquestBlockConfig> for RquestBlock {
if value.is_none() {
self.log.warn("get path failed");
return Action::Continue;
return HeaderAction::Continue;
}
let mut request_url = value.unwrap().clone();
@@ -175,7 +178,7 @@ impl HttpContextWrapper<RquestBlockConfig> for RquestBlock {
Vec::new(),
Some(config.blocked_message.as_bytes()),
);
return Action::Pause;
return HeaderAction::StopIteration;
}
}
for block_url in &config.block_urls {
@@ -185,7 +188,7 @@ impl HttpContextWrapper<RquestBlockConfig> for RquestBlock {
Vec::new(),
Some(config.blocked_message.as_bytes()),
);
return Action::Pause;
return HeaderAction::StopIteration;
}
}
@@ -196,7 +199,7 @@ impl HttpContextWrapper<RquestBlockConfig> for RquestBlock {
Vec::new(),
Some(config.blocked_message.as_bytes()),
);
return Action::Pause;
return HeaderAction::StopIteration;
}
}
}
@@ -214,19 +217,19 @@ impl HttpContextWrapper<RquestBlockConfig> for RquestBlock {
Vec::new(),
Some(config.blocked_message.as_bytes()),
);
return Action::Pause;
return HeaderAction::StopIteration;
}
}
}
Action::Continue
HeaderAction::Continue
}
fn on_http_request_body_ok(&mut self, req_body: &Bytes) -> Action {
fn on_http_request_complete_body(&mut self, req_body: &Bytes) -> DataAction {
if self.config.is_none() {
return Action::Continue;
return DataAction::Continue;
}
let config = self.config.as_ref().unwrap();
if config.block_bodies.is_empty() {
return Action::Continue;
return DataAction::Continue;
}
let mut body = req_body.clone();
if !config.case_sensitive {
@@ -240,9 +243,9 @@ impl HttpContextWrapper<RquestBlockConfig> for RquestBlock {
Vec::new(),
Some(config.blocked_message.as_bytes()),
);
return Action::Pause;
return DataAction::StopIterationAndBuffer;
}
}
Action::Continue
DataAction::Continue
}
}

View File

@@ -4,15 +4,22 @@ version = 3
[[package]]
name = "ahash"
version = "0.8.3"
version = "0.8.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2c99f64d1e06488f620f932677e24bc6e2897582980441ae90a671415bd7ec2f"
checksum = "e89da841a80418a9b391ebaea17f5c112ffaaa96f621d2c285b5174da76b9011"
dependencies = [
"cfg-if",
"once_cell",
"version_check",
"zerocopy",
]
[[package]]
name = "allocator-api2"
version = "0.2.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5c6cb57a04249c6480766f7f7cef5467412af1490f8d1e243141daddada3264f"
[[package]]
name = "cfg-if"
version = "1.0.0"
@@ -21,9 +28,9 @@ checksum = "baf1de4339761588bc0619e3cbc0120ee582ebb74b53b4efbf79117bd2da40fd"
[[package]]
name = "getrandom"
version = "0.2.9"
version = "0.2.15"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c85e1d9ab2eadba7e5040d4e09cbd6d072b76a557ad64e797c2cb9d4da21d7e4"
checksum = "c4567c8db10ae91089c99af84c68c38da3ec2f087c3f82960bcdbf3656b6f4d7"
dependencies = [
"cfg-if",
"libc",
@@ -32,17 +39,19 @@ dependencies = [
[[package]]
name = "hashbrown"
version = "0.13.2"
version = "0.14.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "43a3c133739dddd0d2990f9a4bdf8eb4b21ef50e4851ca85ab661199821d510e"
checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1"
dependencies = [
"ahash",
"allocator-api2",
]
[[package]]
name = "higress-wasm-rust"
version = "0.1.0"
dependencies = [
"multimap",
"proxy-wasm",
"serde",
"serde_json",
@@ -51,42 +60,56 @@ dependencies = [
[[package]]
name = "itoa"
version = "1.0.6"
version = "1.0.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "453ad9f582a441959e5f0d088b02ce04cfe8d51a8eaf077f12ac6d3e94164ca6"
checksum = "49f1f14873335454500d59611f1cf4a4b0f786f9ac11f4312a78e4cf2566695b"
[[package]]
name = "libc"
version = "0.2.144"
version = "0.2.155"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2b00cc1c228a6782d0f076e7b232802e0c5689d41bb5df366f2a6b6621cfdfe1"
checksum = "97b3888a4aecf77e811145cadf6eef5901f4782c53886191b2f693f24761847c"
[[package]]
name = "log"
version = "0.4.18"
version = "0.4.22"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "518ef76f2f87365916b142844c16d8fefd85039bc5699050210a7778ee1cd1de"
checksum = "a7a70ba024b9dc04c27ea2f0c0548feb474ec5c54bba33a7f72f873a39d07b24"
[[package]]
name = "memchr"
version = "2.7.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "78ca9ab1a0babb1e7d5695e3530886289c18cf2f87ec19a575a0abdce112e3a3"
[[package]]
name = "multimap"
version = "0.10.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "defc4c55412d89136f966bbb339008b474350e5e6e78d2714439c386b3137a03"
dependencies = [
"serde",
]
[[package]]
name = "once_cell"
version = "1.17.2"
version = "1.19.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9670a07f94779e00908f3e686eab508878ebb390ba6e604d3a284c00e8d0487b"
checksum = "3fdb12b2476b595f9358c5161aa467c2438859caa136dec86c26fdd2efe17b92"
[[package]]
name = "proc-macro2"
version = "1.0.59"
version = "1.0.86"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6aeca18b86b413c660b781aa319e4e2648a3e6f9eadc9b47e9038e6fe9f3451b"
checksum = "5e719e8df665df0d1c8fbfd238015744736151d4445ec0836b8e628aae103b77"
dependencies = [
"unicode-ident",
]
[[package]]
name = "proxy-wasm"
version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "823b744520cd4a54ba7ebacbffe4562e839d6dcd8f89209f96a1ace4f5229cd4"
version = "0.2.2"
source = "git+https://github.com/higress-group/proxy-wasm-rust-sdk?branch=main#73833051f57d483570cf5aaa9d62bd7402fae63b"
dependencies = [
"hashbrown",
"log",
@@ -94,18 +117,18 @@ dependencies = [
[[package]]
name = "quote"
version = "1.0.28"
version = "1.0.36"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1b9ab9c7eadfd8df19006f1cf1a4aed13540ed5cbc047010ece5826e10825488"
checksum = "0fa76aaf39101c457836aec0ce2316dbdc3ab723cdda1c6bd4e6ad4208acaca7"
dependencies = [
"proc-macro2",
]
[[package]]
name = "ryu"
version = "1.0.13"
version = "1.0.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f91339c0467de62360649f8d3e185ca8de4224ff281f66000de5eb2a77a79041"
checksum = "f3cb5ba0dc43242ce17de99c180e96db90b235b8a9fdc9543c96d2209116bd9f"
[[package]]
name = "say-hello"
@@ -119,18 +142,18 @@ dependencies = [
[[package]]
name = "serde"
version = "1.0.163"
version = "1.0.207"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2113ab51b87a539ae008b5c6c02dc020ffa39afd2d83cffcb3f4eb2722cebec2"
checksum = "5665e14a49a4ea1b91029ba7d3bca9f299e1f7cfa194388ccc20f14743e784f2"
dependencies = [
"serde_derive",
]
[[package]]
name = "serde_derive"
version = "1.0.163"
version = "1.0.207"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8c805777e3930c8883389c602315a24224bcc738b63905ef87cd1420353ea93e"
checksum = "6aea2634c86b0e8ef2cfdc0c340baede54ec27b1e46febd7f80dffb2aa44a00e"
dependencies = [
"proc-macro2",
"quote",
@@ -139,20 +162,21 @@ dependencies = [
[[package]]
name = "serde_json"
version = "1.0.96"
version = "1.0.125"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "057d394a50403bcac12672b2b18fb387ab6d289d957dab67dd201875391e52f1"
checksum = "83c8e735a073ccf5be70aa8066aa984eaf2fa000db6c8d0100ae605b366d31ed"
dependencies = [
"itoa",
"memchr",
"ryu",
"serde",
]
[[package]]
name = "syn"
version = "2.0.18"
version = "2.0.74"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "32d41677bcbe24c20c52e7c70b0d8db04134c5d1066bf98662e2871ad200ea3e"
checksum = "1fceb41e3d546d0bd83421d3409b1460cc7444cd389341a4c880fe7a042cb3d7"
dependencies = [
"proc-macro2",
"quote",
@@ -161,27 +185,47 @@ dependencies = [
[[package]]
name = "unicode-ident"
version = "1.0.9"
version = "1.0.12"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b15811caf2415fb889178633e7724bad2509101cde276048e013b9def5e51fa0"
checksum = "3354b9ac3fae1ff6755cb6db53683adb661634f67557942dea4facebec0fee4b"
[[package]]
name = "uuid"
version = "1.3.3"
version = "1.10.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "345444e32442451b267fc254ae85a209c64be56d2890e601a0c37ff0c3c5ecd2"
checksum = "81dfa00651efa65069b0b6b651f4aaa31ba9e3c3ce0137aaad053604ee7e0314"
dependencies = [
"getrandom",
]
[[package]]
name = "version_check"
version = "0.9.4"
version = "0.9.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "49874b5167b65d7193b8aba1567f5c7d93d001cafc34600cee003eda787e483f"
checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a"
[[package]]
name = "wasi"
version = "0.11.0+wasi-snapshot-preview1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9c8d87e72b64a3b4db28d11ce29237c246188f4f51057d65a7eab63b7987e423"
[[package]]
name = "zerocopy"
version = "0.7.35"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1b9b4fd18abc82b8136838da5d50bae7bdea537c574d8dc1a34ed098d6c166f0"
dependencies = [
"zerocopy-derive",
]
[[package]]
name = "zerocopy-derive"
version = "0.7.35"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fa4f8080344d4671fb4e831a13ad1e68092748387dfc4f55e356242fae12ce3e"
dependencies = [
"proc-macro2",
"quote",
"syn",
]

View File

@@ -10,6 +10,6 @@ crate-type = ["cdylib"]
[dependencies]
higress-wasm-rust = { path = "../../", version = "0.1.0" }
proxy-wasm = "0.2.1"
proxy-wasm = { git="https://github.com/higress-group/proxy-wasm-rust-sdk", branch="main", version="0.2.2" }
serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0"
serde_json = "1.0"

View File

@@ -15,7 +15,7 @@
use higress_wasm_rust::log::Log;
use higress_wasm_rust::rule_matcher::{on_configure, RuleMatcher, SharedRuleMatcher};
use proxy_wasm::traits::{Context, HttpContext, RootContext};
use proxy_wasm::types::{Action, ContextType, LogLevel};
use proxy_wasm::types::{ContextType, HeaderAction, LogLevel};
use serde::Deserialize;
use std::cell::RefCell;
use std::ops::DerefMut;
@@ -75,12 +75,16 @@ impl RootContext for SayHelloRoot {
impl Context for SayHello {}
impl HttpContext for SayHello {
fn on_http_request_headers(&mut self, _num_headers: usize, _end_of_stream: bool) -> Action {
fn on_http_request_headers(
&mut self,
_num_headers: usize,
_end_of_stream: bool,
) -> HeaderAction {
let binding = self.rule_matcher.borrow();
let config = match binding.get_match_config() {
None => {
self.send_http_response(200, vec![], Some("Hello, World!".as_bytes()));
return Action::Continue;
return HeaderAction::Continue;
}
Some(config) => config.1,
};
@@ -90,6 +94,6 @@ impl HttpContext for SayHello {
vec![],
Some(format!("Hello, {}!", config.name).as_bytes()),
);
Action::Continue
HeaderAction::Continue
}
}

View File

@@ -15,7 +15,7 @@
use crate::rule_matcher::SharedRuleMatcher;
use multimap::MultiMap;
use proxy_wasm::traits::{Context, HttpContext, RootContext};
use proxy_wasm::types::{Action, Bytes};
use proxy_wasm::types::{Action, Bytes, DataAction, HeaderAction};
use serde::de::DeserializeOwned;
pub trait RootContextWrapper<PluginConfig>: RootContext
@@ -43,8 +43,17 @@ where
}
pub trait HttpContextWrapper<PluginConfig>: HttpContext {
fn on_config(&mut self, _config: &PluginConfig) {}
fn on_http_request_headers_ok(&mut self, _headers: &MultiMap<String, String>) -> Action {
Action::Continue
fn on_http_request_complete_headers(
&mut self,
_headers: &MultiMap<String, String>,
) -> HeaderAction {
HeaderAction::Continue
}
fn on_http_response_complete_headers(
&mut self,
_headers: &MultiMap<String, String>,
) -> HeaderAction {
HeaderAction::Continue
}
fn cache_request_body(&self) -> bool {
false
@@ -52,11 +61,11 @@ pub trait HttpContextWrapper<PluginConfig>: HttpContext {
fn cache_response_body(&self) -> bool {
false
}
fn on_http_request_body_ok(&mut self, _req_body: &Bytes) -> Action {
Action::Continue
fn on_http_request_complete_body(&mut self, _req_body: &Bytes) -> DataAction {
DataAction::Continue
}
fn on_http_response_body_ok(&mut self, _res_body: &Bytes) -> Action {
Action::Continue
fn on_http_response_complete_body(&mut self, _res_body: &Bytes) -> DataAction {
DataAction::Continue
}
fn replace_http_request_body(&mut self, body: &[u8]) {
self.set_http_request_body(0, i32::MAX as usize, body)
@@ -67,6 +76,7 @@ pub trait HttpContextWrapper<PluginConfig>: HttpContext {
}
pub struct PluginHttpWrapper<PluginConfig> {
req_headers: MultiMap<String, String>,
res_headers: MultiMap<String, String>,
req_body_len: usize,
res_body_len: usize,
config: Option<PluginConfig>,
@@ -80,6 +90,7 @@ impl<PluginConfig> PluginHttpWrapper<PluginConfig> {
) -> Self {
PluginHttpWrapper {
req_headers: MultiMap::new(),
res_headers: MultiMap::new(),
req_body_len: 0,
res_body_len: 0,
config: None,
@@ -129,7 +140,7 @@ impl<PluginConfig> HttpContext for PluginHttpWrapper<PluginConfig>
where
PluginConfig: Default + DeserializeOwned + Clone,
{
fn on_http_request_headers(&mut self, num_headers: usize, end_of_stream: bool) -> Action {
fn on_http_request_headers(&mut self, num_headers: usize, end_of_stream: bool) -> HeaderAction {
let binding = self.rule_matcher.borrow();
self.config = binding.get_match_config().map(|config| config.1.clone());
for (k, v) in self.get_http_request_headers() {
@@ -141,23 +152,22 @@ where
let ret = self
.http_content
.on_http_request_headers(num_headers, end_of_stream);
if ret != Action::Continue {
if ret != HeaderAction::Continue {
return ret;
}
self.http_content
.on_http_request_headers_ok(&self.req_headers)
.on_http_request_complete_headers(&self.req_headers)
}
fn on_http_request_body(&mut self, body_size: usize, end_of_stream: bool) -> Action {
fn on_http_request_body(&mut self, body_size: usize, end_of_stream: bool) -> DataAction {
if !self.http_content.cache_request_body() {
return self
.http_content
.on_http_request_body(body_size, end_of_stream);
}
self.req_body_len += body_size;
if !end_of_stream {
return Action::Pause;
}
let ret = self
.http_content
.on_http_request_body(self.req_body_len, end_of_stream);
if ret != Action::Continue || !self.http_content.cache_request_body() {
return ret;
return DataAction::StopIterationAndBuffer;
}
let mut req_body = Bytes::new();
if self.req_body_len > 0 {
@@ -165,30 +175,42 @@ where
req_body = body;
}
}
self.http_content.on_http_request_body_ok(&req_body)
self.http_content.on_http_request_complete_body(&req_body)
}
fn on_http_request_trailers(&mut self, num_trailers: usize) -> Action {
self.http_content.on_http_request_trailers(num_trailers)
}
fn on_http_response_headers(&mut self, num_headers: usize, end_of_stream: bool) -> Action {
self.http_content
.on_http_response_headers(num_headers, end_of_stream)
}
fn on_http_response_body(&mut self, body_size: usize, end_of_stream: bool) -> Action {
self.res_body_len += body_size;
if !end_of_stream {
return Action::Pause;
fn on_http_response_headers(
&mut self,
num_headers: usize,
end_of_stream: bool,
) -> HeaderAction {
for (k, v) in self.get_http_response_headers() {
self.res_headers.insert(k, v);
}
let ret = self
.http_content
.on_http_response_body(self.res_body_len, end_of_stream);
if ret != Action::Continue || !self.http_content.cache_response_body() {
.on_http_response_headers(num_headers, end_of_stream);
if ret != HeaderAction::Continue {
return ret;
}
self.http_content
.on_http_response_complete_headers(&self.res_headers)
}
fn on_http_response_body(&mut self, body_size: usize, end_of_stream: bool) -> DataAction {
if !self.http_content.cache_response_body() {
return self
.http_content
.on_http_response_body(body_size, end_of_stream);
}
self.res_body_len += body_size;
if !end_of_stream {
return DataAction::StopIterationAndBuffer;
}
let mut res_body = Bytes::new();
if self.res_body_len > 0 {
@@ -196,7 +218,7 @@ where
res_body = body;
}
}
self.http_content.on_http_response_body_ok(&res_body)
self.http_content.on_http_response_complete_body(&res_body)
}
fn on_http_response_trailers(&mut self, num_trailers: usize) -> Action {

View File

@@ -0,0 +1,115 @@
// Copyright (c) 2022 Alibaba Group Holding Ltd.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package tests
import (
"testing"
"github.com/alibaba/higress/test/e2e/conformance/utils/http"
"github.com/alibaba/higress/test/e2e/conformance/utils/suite"
)
func init() {
Register(WasmPluginsAiProxy)
}
var WasmPluginsAiProxy = suite.ConformanceTest{
ShortName: "WasmPluginAiProxy",
Description: "The Ingress in the higress-conformance-infra namespace test the ai-proxy WASM plugin.",
Features: []suite.SupportedFeature{suite.WASMGoConformanceFeature},
Manifests: []string{"tests/go-wasm-ai-proxy.yaml"},
Test: func(t *testing.T, suite *suite.ConformanceTestSuite) {
testcases := []http.Assertion{
{
Meta: http.AssertionMeta{
TestCaseName: "case 1: openai",
TargetBackend: "infra-backend-v1",
TargetNamespace: "higress-conformance-infra",
},
Request: http.AssertionRequest{
ActualRequest: http.Request{
Host: "openai.ai.com",
Path: "/v1/chat/completions",
Method:"POST",
ContentType: http.ContentTypeApplicationJson,
Body: []byte(`{
"model": "gpt-3",
"messages": [{"role":"user","content":"hi"}]}`),
},
ExpectedRequest: &http.ExpectedRequest{
Request: http.Request{
Host: "api.openai.com",
Path: "/v1/chat/completions",
Method: "POST",
ContentType: http.ContentTypeApplicationJson,
Body: []byte(`{
"model": "gpt-3",
"messages": [{"role":"user","content":"hi"}],
"max_tokens": 123,
"temperature": 0.66}`),
},
},
},
Response: http.AssertionResponse{
ExpectedResponse: http.Response{
StatusCode: 200,
},
},
},
{
Meta: http.AssertionMeta{
TestCaseName: "case 2: qwen",
TargetBackend: "infra-backend-v1",
TargetNamespace: "higress-conformance-infra",
},
Request: http.AssertionRequest{
ActualRequest: http.Request{
Host: "qwen.ai.com",
Path: "/v1/chat/completions",
Method:"POST",
ContentType: http.ContentTypeApplicationJson,
Body: []byte(`{
"model": "qwen-long",
"input": {"messages": [{"role":"user","content":"hi"}]},
"parameters": {"max_tokens": 321, "temperature": 0.7}}`),
},
ExpectedRequest: &http.ExpectedRequest{
Request: http.Request{
Host: "dashscope.aliyuncs.com",
Path: "/api/v1/services/aigc/text-generation/generation",
Method: "POST",
ContentType: http.ContentTypeApplicationJson,
Body: []byte(`{
"model": "qwen-long",
"input": {"messages": [{"role":"user","content":"hi"}]},
"parameters": {"max_tokens": 321, "temperature": 0.66}}`),
},
},
},
Response: http.AssertionResponse{
ExpectedResponse: http.Response{
StatusCode: 500,
},
},
},
}
t.Run("WasmPlugins ai-proxy", func(t *testing.T) {
for _, testcase := range testcases {
http.MakeRequestAndExpectEventuallyConsistentResponse(t, suite.RoundTripper, suite.TimeoutConfig, suite.GatewayAddress, testcase)
}
})
},
}

View File

@@ -0,0 +1,87 @@
# Copyright (c) 2022 Alibaba Group Holding Ltd.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
annotations:
name: wasmplugin-ai-proxy-openai
namespace: higress-conformance-infra
spec:
ingressClassName: higress
rules:
- host: "openai.ai.com"
http:
paths:
- pathType: Prefix
path: "/"
backend:
service:
name: infra-backend-v1
port:
number: 8080
---
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
annotations:
name: wasmplugin-ai-proxy-qwen
namespace: higress-conformance-infra
spec:
ingressClassName: higress
rules:
- host: "qwen.ai.com"
http:
paths:
- pathType: Prefix
path: "/"
backend:
service:
name: infra-backend-v1
port:
number: 8080
---
apiVersion: extensions.higress.io/v1alpha1
kind: WasmPlugin
metadata:
name: ai-proxy
namespace: higress-system
spec:
priority: 200
matchRules:
- config:
provider:
type: "openai"
customSettings:
- name: "max_tokens"
value: 123
overwrite: false
- name: "temperature"
value: 0.66
overwrite: true
ingress:
- higress-conformance-infra/wasmplugin-ai-proxy-openai
- config:
provider:
type: "qwen"
apiTokens: "fake-token"
customSettings:
- name: "max_tokens"
value: 123
overwrite: false
- name: "temperature"
value: 0.66
overwrite: true
ingress:
- higress-conformance-infra/wasmplugin-ai-proxy-qwen
url: file:///opt/plugins/wasm-go/extensions/ai-proxy/plugin.wasm

View File

@@ -0,0 +1,189 @@
// Copyright (c) 2022 Alibaba Group Holding Ltd.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package tests
import (
"testing"
"github.com/alibaba/higress/test/e2e/conformance/utils/http"
"github.com/alibaba/higress/test/e2e/conformance/utils/suite"
)
func init() {
Register(RustWasmPluginsAiDataMasking)
}
func gen_assertion(host string, req_is_json bool, req_body []byte, res_body []byte) http.Assertion {
var content_type string
if req_is_json {
content_type = http.ContentTypeApplicationJson
} else {
content_type = http.ContentTypeTextPlain
}
return http.Assertion{
Meta: http.AssertionMeta{
CompareTarget: http.CompareTargetResponse,
},
Request: http.AssertionRequest{
ActualRequest: http.Request{
Host: host,
Path: "/",
Method: "POST",
ContentType: content_type,
Body: req_body,
UnfollowRedirect: true,
},
},
Response: http.AssertionResponse{
ExpectedResponse: http.Response{
ContentType: http.ContentTypeApplicationJson,
Body: res_body,
},
},
}
}
var RustWasmPluginsAiDataMasking = suite.ConformanceTest{
ShortName: "RustWasmPluginsAiDataMasking",
Description: "The Ingress in the higress-conformance-infra namespace test the rust ai-data-masking wasmplugins.",
Manifests: []string{"tests/rust-wasm-ai-data-masking.yaml"},
Features: []suite.SupportedFeature{suite.WASMRustConformanceFeature},
Test: func(t *testing.T, suite *suite.ConformanceTestSuite) {
var testcases []http.Assertion
//openai
testcases = append(testcases, gen_assertion(
"replace.openai.com",
true,
[]byte("{\"messages\":[{\"role\":\"user\",\"content\":\"127.0.0.1 admin@gmail.com sk-12345\"}]}"),
[]byte("{\"choices\":[{\"index\":0,\"message\":{\"role\":\"assistant\",\"content\":\"127.0.0.1 sk-12345 admin@gmail.com\"}}],\"usage\":{}}"),
))
testcases = append(testcases, gen_assertion(
"replace.openai.com",
true,
[]byte("{\"messages\":[{\"role\":\"user\",\"content\":\"192.168.0.1 root@gmail.com sk-12345\"}]}"),
[]byte("{\"choices\":[{\"index\":0,\"message\":{\"role\":\"assistant\",\"content\":\"192.168.0.1 sk-12345 root@gmail.com\"}}],\"usage\":{}}"),
))
testcases = append(testcases, gen_assertion(
"ok.openai.com",
true,
[]byte("{\"messages\":[{\"role\":\"user\",\"content\":\"fuck\"}]}"),
[]byte("{\"choices\":[{\"index\":0,\"message\":{\"role\":\"assistant\",\"content\":\"提问或回答中包含敏感词,已被屏蔽\"}}],\"usage\":{}}"),
))
testcases = append(testcases, gen_assertion(
"ok.openai.com",
true,
[]byte("{\"messages\":[{\"role\":\"user\",\"content\":\"costom_word1\"}]}"),
[]byte("{\"choices\":[{\"index\":0,\"message\":{\"role\":\"assistant\",\"content\":\"提问或回答中包含敏感词,已被屏蔽\"}}],\"usage\":{}}"),
))
testcases = append(testcases, gen_assertion(
"ok.openai.com",
true,
[]byte("{\"messages\":[{\"role\":\"user\",\"content\":\"costom_word\"}]}"),
[]byte("{\"choices\":[{\"index\":0,\"message\":{\"role\":\"assistant\",\"content\":\"ok\"}}],\"usage\":{}}"),
))
testcases = append(testcases, gen_assertion(
"system_deny.openai.com",
true,
[]byte("{\"messages\":[{\"role\":\"user\",\"content\":\"test\"}]}"),
[]byte("{\"choices\":[{\"index\":0,\"message\":{\"role\":\"assistant\",\"content\":\"提问或回答中包含敏感词,已被屏蔽\"}}],\"usage\":{}}"),
))
testcases = append(testcases, gen_assertion(
"costom_word1.openai.com",
true,
[]byte("{\"messages\":[{\"role\":\"user\",\"content\":\"test\"}]}"),
[]byte("{\"choices\":[{\"index\":0,\"message\":{\"role\":\"assistant\",\"content\":\"提问或回答中包含敏感词,已被屏蔽\"}}],\"usage\":{}}"),
))
testcases = append(testcases, gen_assertion(
"costom_word.openai.com",
true,
[]byte("{\"messages\":[{\"role\":\"user\",\"content\":\"test\"}]}"),
[]byte("{\"choices\":[{\"index\":0,\"message\":{\"role\":\"assistant\",\"content\":\"costom_word\"}}],\"usage\":{}}"),
))
//raw
testcases = append(testcases, gen_assertion(
"replace.raw.com",
false,
[]byte("127.0.0.1 admin@gmail.com sk-12345"),
[]byte("{\"res\":\"127.0.0.1 sk-12345 admin@gmail.com\"}"),
))
testcases = append(testcases, gen_assertion(
"replace.raw.com",
false,
[]byte("192.168.0.1 root@gmail.com sk-12345"),
[]byte("{\"res\":\"192.168.0.1 sk-12345 root@gmail.com\"}"),
))
testcases = append(testcases, gen_assertion(
"ok.raw.com",
false,
[]byte("fuck"),
[]byte("{\"errmsg\":\"提问或回答中包含敏感词,已被屏蔽\"}"),
))
testcases = append(testcases, gen_assertion(
"ok.raw.com",
false,
[]byte("costom_word1"),
[]byte("{\"errmsg\":\"提问或回答中包含敏感词,已被屏蔽\"}"),
))
testcases = append(testcases, gen_assertion(
"ok.raw.com",
false,
[]byte("costom_word"),
[]byte("{\"res\":\"ok\"}"),
))
testcases = append(testcases, gen_assertion(
"system_deny.raw.com",
false,
[]byte("test"),
[]byte("{\"errmsg\":\"提问或回答中包含敏感词,已被屏蔽\"}"),
))
testcases = append(testcases, gen_assertion(
"costom_word1.raw.com",
false,
[]byte("test"),
[]byte("{\"errmsg\":\"提问或回答中包含敏感词,已被屏蔽\"}"),
))
testcases = append(testcases, gen_assertion(
"costom_word.raw.com",
false,
[]byte("test"),
[]byte("{\"res\":\"costom_word\"}"),
))
//jsonpath
testcases = append(testcases, gen_assertion(
"replace.raw.com",
true,
[]byte("{\"test\":[{\"test\":\"127.0.0.1 admin@gmail.com sk-12345\"}]}"),
[]byte("{\"res\":\"127.0.0.1 sk-12345 admin@gmail.com\"}"),
))
testcases = append(testcases, gen_assertion(
"replace.raw.com",
true,
[]byte("{\"test\":[{\"test\":\"test\", \"test1\":\"127.0.0.1 admin@gmail.com sk-12345\"}]}"),
[]byte("{\"res\":\"***.***.***.*** 48a7e98a91d93896d8dac522c5853948 ****@gmail.com\"}"),
))
t.Run("WasmPlugins ai-data-masking", func(t *testing.T) {
for _, testcase := range testcases {
http.MakeRequestAndExpectEventuallyConsistentResponse(t, suite.RoundTripper, suite.TimeoutConfig, suite.GatewayAddress, testcase)
}
})
},
}

View File

@@ -0,0 +1,150 @@
# Copyright (c) 2022 Alibaba Group Holding Ltd.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: wasmplugin-ai-data-masking
namespace: higress-conformance-infra
spec:
ingressClassName: higress
rules:
- host: "*.openai.com"
http:
paths:
- pathType: Prefix
path: "/"
backend:
service:
name: infra-backend-v1
port:
number: 8080
- host: "*.raw.com"
http:
paths:
- pathType: Prefix
path: "/"
backend:
service:
name: infra-backend-v1
port:
number: 8080
---
apiVersion: extensions.higress.io/v1alpha1
kind: WasmPlugin
metadata:
name: custom-response
namespace: higress-system
spec:
priority: 200
defaultConfig:
"body": "ok"
matchRules:
- domain:
- ok.openai.com
config:
headers:
- Content-Type=application/json
"body": "{\"choices\":[{\"index\":0,\"message\":{\"role\":\"assistant\",\"content\":\"ok\"}}],\"usage\":{}}"
- domain:
- replace.openai.com
config:
headers:
- Content-Type=application/json
"body": "{\"choices\":[{\"index\":0,\"message\":{\"role\":\"assistant\",\"content\":\"***.***.***.*** 48a7e98a91d93896d8dac522c5853948 ****@gmail.com\"}}],\"usage\":{}}"
- domain:
- system_deny.openai.com
config:
headers:
- Content-Type=application/json
"body": "{\"choices\":[{\"index\":0,\"message\":{\"role\":\"assistant\",\"content\":\"fuck\"}}],\"usage\":{}}"
- domain:
- costom_word1.openai.com
config:
headers:
- Content-Type=application/json
"body": "{\"choices\":[{\"index\":0,\"message\":{\"role\":\"assistant\",\"content\":\"costom_word1\"}}],\"usage\":{}}"
- domain:
- costom_word.openai.com
config:
headers:
- Content-Type=application/json
"body": "{\"choices\":[{\"index\":0,\"message\":{\"role\":\"assistant\",\"content\":\"costom_word\"}}],\"usage\":{}}"
- domain:
- ok.raw.com
config:
headers:
- Content-Type=application/json
"body": "{\"res\":\"ok\"}"
- domain:
- replace.raw.com
config:
headers:
- Content-Type=application/json
"body": "{\"res\":\"***.***.***.*** 48a7e98a91d93896d8dac522c5853948 ****@gmail.com\"}"
- domain:
- system_deny.raw.com
config:
headers:
- Content-Type=application/json
"body": "{\"res\":\"fuck\"}"
- domain:
- costom_word1.raw.com
config:
headers:
- Content-Type=application/json
"body": "{\"res\":\"costom_word1\"}"
- domain:
- costom_word.raw.com
config:
headers:
- Content-Type=application/json
"body": "{\"res\":\"costom_word\"}"
url: file:///opt/plugins/wasm-go/extensions/custom-response/plugin.wasm
---
apiVersion: extensions.higress.io/v1alpha1
kind: WasmPlugin
metadata:
name: ai-data-masking
namespace: higress-system
spec:
priority: 300
defaultConfig:
system_deny: true
deny_openai: true
deny_jsonpath:
- "$.test[*].test"
deny_raw: true
deny_code: 200
deny_message: "提问或回答中包含敏感词,已被屏蔽"
deny_raw_message: "{\"errmsg\":\"提问或回答中包含敏感词,已被屏蔽\"}"
deny_content_type: "application/json"
deny_words:
- "costom_word1"
replace_roles:
- regex: "%{EMAILLOCALPART}@%{HOSTNAME:domain}"
type: "replace"
restore: true
value: "****@$domain"
- regex: "%{IP}"
type: "replace"
restore: true
value: "***.***.***.***"
- regex: "sk-[0-9a-zA-Z]*"
restore: true
type: "hash"
url: file:///opt/plugins/wasm-rust/extensions/ai-data-masking/plugin.wasm

View File

@@ -32,6 +32,7 @@ then
elif [ "$TYPE" == "RUST" ]
then
cd ./plugins/wasm-rust/
make lint-base
if [ ! -n "$INNER_PLUGIN_NAME" ]; then
EXTENSIONS_DIR=$(pwd)"/extensions/"
echo "🚀 Build all Rust WasmPlugins under folder of $EXTENSIONS_DIR"
@@ -40,12 +41,21 @@ then
if [ -d $EXTENSIONS_DIR$file ]; then
name=${file##*/}
echo "🚀 Build Rust WasmPlugin: $name"
PLUGIN_NAME=${name} make lint
PLUGIN_NAME=${name} BUILDER_REGISTRY="docker.io/alihigress/plugins-rust-" make build
fi
done
cd ../wasm-go/
PLUGIN_NAME=custom-response make build
else
echo "🚀 Build Rust WasmPlugin: $INNER_PLUGIN_NAME"
PLUGIN_NAME=${INNER_PLUGIN_NAME} make lint
PLUGIN_NAME=${INNER_PLUGIN_NAME} make build
if [ "$INNER_PLUGIN_NAME" == "ai-data-masking" ]; then
cd ../wasm-go/
PLUGIN_NAME=custom-response make build
fi
fi
else
echo "Not specify plugin language, so just compile wasm-go as default"