Compare commits

...

54 Commits

Author SHA1 Message Date
johnlanni
7697af9d2b update chart.lock 2024-11-08 14:07:12 +08:00
澄潭
3660715506 release 2.0.3 (#1494) 2024-11-08 13:59:12 +08:00
Squidward
7bd438877b add textin embedding for ai-cache (#1493) 2024-11-08 13:48:32 +08:00
Ranjana761
0fbeb39cac docs: Added back to top , contributors section and star history graph (#1440) 2024-11-08 10:18:16 +08:00
Kent Dong
d02c974af4 feat: Ensure all images are loaded to K8s before starting e2e tests (#1389) 2024-11-08 10:15:51 +08:00
澄潭
8ad4970231 update rust makefile (#1491) 2024-11-08 10:14:02 +08:00
007gzs
aee37c5e22 Implement Rust Wasm Plugin Build & Publish Action (#1483) 2024-11-07 20:20:42 +08:00
Kent Dong
73cf32aadd feat: Update istio codebase (#1485) 2024-11-07 11:40:57 +08:00
澄潭
1ab69fcf82 Update README.md 2024-11-07 11:20:25 +08:00
mamba
9b995321bb feat: 🎸 支持多版本能力:根据不同路由映射不同的Version版本。 (#1429) 2024-11-07 09:11:57 +08:00
澄潭
00cac813e3 add clusterrole for gateway api (#1480) 2024-11-06 13:37:18 +08:00
澄潭
548cf2f081 move nottinygc to proxy-wasm-go-sdk (#1477) 2024-11-05 20:59:51 +08:00
007gzs
c1f2504e87 Ai data mask deny word match optimize (#1453) 2024-11-05 15:26:55 +08:00
rinfx
7e8b0445ad modify log-format, then every plugin log is associated with access log (#1454) 2024-11-04 22:04:20 +08:00
littlejian
63d5422da6 feat:Support downstream and upstram, which can be configured through helm parameters (#1399) 2024-11-01 22:39:19 +08:00
韩贤涛
035e81a5ca fix: Control gateway-api Listener with global.enableGatewayAPI in Helm (#1461) 2024-10-31 21:36:40 +08:00
澄潭
9a1edcd4c8 Fix the issue where wasmplugin does not work when kingress exists (#1450) 2024-10-29 20:27:11 +08:00
007gzs
2219a17898 [feat] Support redis call with wasm-rust (#1417) 2024-10-29 19:35:02 +08:00
纪卓志
93c1e5c2bb feat: lazy formatted log (#1441) 2024-10-29 17:06:03 +08:00
澄潭
7c2d2b2855 fix destinationrule merge logic (#1439) 2024-10-29 09:01:06 +08:00
澄潭
b1550e91ab rel: Release v2.0.2 (#1438) 2024-10-28 19:14:57 +08:00
澄潭
0b42836e85 Update CODEOWNERS 2024-10-28 18:56:09 +08:00
tmsnan
7c33ebf6ea bugfix: remove config check and fix memory leak in traffic-tag (#1435) 2024-10-28 16:26:29 +08:00
Yang Beining
acec48ed8b [ai-cache] Implement a WASM plugin for LLM result retrieval based on vector similarity (#1290) 2024-10-27 16:21:04 +08:00
澄潭
d309bf2e25 fix model-router plugin (#1432) 2024-10-25 16:48:37 +08:00
韩贤涛
496d365a95 add PILOT_ENABLE_ALPHA_GATEWAY_API and fix gateway status update (#1421) 2024-10-24 20:57:46 +08:00
rinfx
d952fa562b bugfix: plugin will block GET request (#1428) 2024-10-24 17:34:26 +08:00
纪卓志
e7561c30e5 feat: implements text/event-stream(SSE) MIME parser (#1416)
Co-authored-by: 007gzs <007gzs@gmail.com>
2024-10-24 16:58:45 +08:00
澄潭
cdd71155a9 Update README.md 2024-10-24 11:25:59 +08:00
Ankur Singh
a5ccb90b28 Improve the grammar of the sentence (#1426) 2024-10-24 09:38:22 +08:00
007gzs
d76f574ab3 plugin ai-data-mask add log (#1423) 2024-10-23 13:19:02 +08:00
mamba
bb6c43c767 feat: 【frontend-gray】添加 skipedRoutes以及skipedByHeaders 配置 (#1409)
Co-authored-by: Kent Dong <ch3cho@qq.com>
2024-10-23 09:34:00 +08:00
fengxsong
b8f5826a32 fix: do not create ingressclass when it's empty (#1419)
Signed-off-by: fengxusong <fengxsong@outlook.com>
2024-10-23 09:03:07 +08:00
Bingkun Zhao
0d79386ce2 fix a bug of ip-restriction plugin (#1422) 2024-10-22 22:17:56 +08:00
007gzs
871ae179c3 Ai data masking fix (#1420) 2024-10-22 21:39:01 +08:00
澄潭
f8d62a8ac3 add model router plugin (#1414) 2024-10-21 16:46:18 +08:00
澄潭
badf4b7101 ai cache plugin support set skip ai cache header (#1380) 2024-10-21 15:43:01 +08:00
Ikko Eltociear Ashimine
fc6902ded2 docs: add Japanese README and CONTRIBUTING files (#1407) 2024-10-21 15:20:45 +08:00
007gzs
d96994767c Change http_content to Rc in HttpWrapper (#1391) 2024-10-21 09:44:01 +08:00
rinfx
32e5a59ae0 fix special charactor handle in ai-security-guard plugin (#1394) 2024-10-18 16:32:48 +08:00
Lisheng Zheng
49bb5ec2b9 fix: add HTTP2 protocol options to skywalking and otel cluster (#1379) 2024-10-18 15:34:34 +08:00
mamba
11ff2d1d31 [frontend-gray] support grayKey from localStorage (#1395) 2024-10-18 13:58:52 +08:00
澄潭
c67f494b49 Update README.md 2024-10-18 09:54:10 +08:00
澄潭
299621476f Update README.md 2024-10-17 14:33:08 +08:00
澄潭
7e6168a644 Update README.md 2024-10-17 14:31:26 +08:00
Smoothengineer
e923cbaecc Update README_EN.md (#1402) 2024-10-17 12:53:04 +08:00
Kent Dong
6f86c31bac feat: Update submodules: envoy/envoy, istio/isitio (#1398) 2024-10-16 19:00:18 +08:00
Kent Dong
51c956f0b3 fix: Fix clean targets in Makefile (#1397) 2024-10-16 18:42:49 +08:00
澄潭
d0693d8c4b Update SECURITY.md 2024-10-16 11:17:44 +08:00
Jun
e298078065 add dns&static registry e2e test (#1393) 2024-10-16 10:21:03 +08:00
澄潭
85f8eb5166 key-auth consumer support set independent key source (#1392) 2024-10-15 20:52:03 +08:00
澄潭
0a112d1a1e fix mcp service port protocol name (#1383) 2024-10-15 11:50:43 +08:00
Kent Dong
04ce776f14 feat: Support route fallback by default (#1381) 2024-10-14 18:50:45 +08:00
rinfx
952c9ec5dc Ai proxy support coze (#1387) 2024-10-14 12:45:53 +08:00
135 changed files with 7728 additions and 1153 deletions

View File

@@ -3,9 +3,16 @@ name: Build and Push Wasm Plugin Image
on:
push:
tags:
- "wasm-go-*-v*.*.*" # 匹配 wasm-go-{pluginName}-vX.Y.Z 格式的标签
- "wasm-*-*-v*.*.*" # 匹配 wasm-{go|rust}-{pluginName}-vX.Y.Z 格式的标签
workflow_dispatch:
inputs:
plugin_type:
description: 'Type of the plugin'
required: true
type: choice
options:
- go
- rust
plugin_name:
description: 'Name of the plugin'
required: true
@@ -23,32 +30,40 @@ jobs:
env:
IMAGE_REGISTRY_SERVICE: ${{ vars.IMAGE_REGISTRY || 'higress-registry.cn-hangzhou.cr.aliyuncs.com' }}
IMAGE_REPOSITORY: ${{ vars.PLUGIN_IMAGE_REPOSITORY || 'plugins' }}
RUST_VERSION: 1.82
GO_VERSION: 1.19
TINYGO_VERSION: 0.28.1
ORAS_VERSION: 1.0.0
steps:
- name: Set plugin_name and version from inputs or ref_name
- name: Set plugin_type, plugin_name and version from inputs or ref_name
id: set_vars
run: |
if [[ "${{ github.event_name }}" == "workflow_dispatch" ]]; then
plugin_type="${{ github.event.inputs.plugin_type }}"
plugin_name="${{ github.event.inputs.plugin_name }}"
version="${{ github.event.inputs.version }}"
builder_image="higress-registry.cn-hangzhou.cr.aliyuncs.com/plugins/wasm-rust-builder:rust${{ env.RUST_VERSION }}-oras${{ env.ORAS_VERSION }}"
else
ref_name=${{ github.ref_name }}
plugin_type=${ref_name#*-} # 删除插件类型前面的字段(wasm-)
plugin_type=${plugin_type%-*} # 删除插件类型后面的字段(-{plugin_name}-vX.Y.Z)
plugin_name=${ref_name#*-*-} # 删除插件名前面的字段(wasm-go-)
plugin_name=${plugin_name%-*} # 删除插件名后面的字段(-vX.Y.Z)
version=$(echo "$ref_name" | awk -F'v' '{print $2}')
builder_image="higress-registry.cn-hangzhou.cr.aliyuncs.com/plugins/wasm-go-builder:go${{ env.GO_VERSION }}-tinygo${{ env.TINYGO_VERSION }}-oras${{ env.ORAS_VERSION }}"
fi
echo "PLUGIN_TYPE=$plugin_type" >> $GITHUB_ENV
echo "PLUGIN_NAME=$plugin_name" >> $GITHUB_ENV
echo "VERSION=$version" >> $GITHUB_ENV
echo "BUILDER_IMAGE=$builder_image" >> $GITHUB_ENV
- name: Checkout code
uses: actions/checkout@v3
- name: File Check
run: |
workspace=${{ github.workspace }}/plugins/wasm-go/extensions/${PLUGIN_NAME}
workspace=${{ github.workspace }}/plugins/wasm-${PLUGIN_TYPE}/extensions/${PLUGIN_NAME}
push_command="./plugin.tar.gz:application/vnd.oci.image.layer.v1.tar+gzip"
# 查找spec.yaml
@@ -75,10 +90,10 @@ jobs:
echo "PUSH_COMMAND=\"$push_command\"" >> $GITHUB_ENV
- name: Run a wasm-go-builder
- name: Run a wasm-builder
env:
PLUGIN_NAME: ${{ env.PLUGIN_NAME }}
BUILDER_IMAGE: higress-registry.cn-hangzhou.cr.aliyuncs.com/plugins/wasm-go-builder:go${{ env.GO_VERSION }}-tinygo${{ env.TINYGO_VERSION }}-oras${{ env.ORAS_VERSION }}
BUILDER_IMAGE: ${{ env.BUILDER_IMAGE }}
run: |
docker run -itd --name builder -v ${{ github.workspace }}:/workspace -e PLUGIN_NAME=${{ env.PLUGIN_NAME }} --rm ${{ env.BUILDER_IMAGE }} /bin/bash
@@ -93,7 +108,7 @@ jobs:
echo "TargetImage=${target_image}"
echo "TargetImageLatest=${target_image_latest}"
cd ${{ github.workspace }}/plugins/wasm-go/extensions/${PLUGIN_NAME}
cd ${{ github.workspace }}/plugins/wasm-${PLUGIN_TYPE}/extensions/${PLUGIN_NAME}
if [ -f ./.buildrc ]; then
echo 'Found .buildrc file, sourcing it...'
. ./.buildrc
@@ -101,7 +116,7 @@ jobs:
echo '.buildrc file not found'
fi
echo "EXTRA_TAGS=${EXTRA_TAGS}"
if [ "${PLUGIN_TYPE}" == "go" ]; then
command="
set -e
cd /workspace/plugins/wasm-go/extensions/${PLUGIN_NAME}
@@ -112,4 +127,21 @@ jobs:
oras push ${target_image} ${push_command}
oras push ${target_image_latest} ${push_command}
"
elif [ "${PLUGIN_TYPE}" == "rust" ]; then
command="
set -e
cd /workspace/plugins/wasm-rust/extensions/${PLUGIN_NAME}
cargo build --target wasm32-wasi --release
cp target/wasm32-wasi/release/*.wasm plugin.wasm
tar czvf plugin.tar.gz plugin.wasm
echo ${{ secrets.REGISTRY_PASSWORD }} | oras login -u ${{ secrets.REGISTRY_USERNAME }} --password-stdin ${{ env.IMAGE_REGISTRY_SERVICE }}
oras push ${target_image} ${push_command}
oras push ${target_image_latest} ${push_command}
"
else
command="
echo "unkown type ${PLUGIN_TYPE}"
"
fi
docker exec builder bash -c "$command"

24
.github/workflows/release-crd.yaml vendored Normal file
View File

@@ -0,0 +1,24 @@
name: Release CRD to GitHub
on:
push:
tags:
- "v*.*.*"
workflow_dispatch: ~
jobs:
release-crd:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: generate crds
run: |
cat helm/core/crds/customresourcedefinitions.gen.yaml helm/core/crds/istio-envoyfilter.yaml > crd.yaml
- name: Upload hgctl packages to the GitHub release
uses: softprops/action-gh-release@v2
if: startsWith(github.ref, 'refs/tags/')
with:
files: |
crd.yaml

View File

@@ -3,7 +3,7 @@
/istio @SpecialYang @johnlanni
/pkg @SpecialYang @johnlanni @CH3CHO
/plugins @johnlanni @WeixinX @CH3CHO
/plugins/wasm-rust @007gzs
/plugins/wasm-rust @007gzs @jizhuozhi
/registry @NameHaibinZhang @2456868764 @johnlanni
/test @Xunzhuo @2456868764 @CH3CHO
/tools @johnlanni @Xunzhuo @2456868764

View File

@@ -1,6 +1,6 @@
# Contributing to Higress
It is warmly welcomed if you have interest to hack on Higress. First, we encourage this kind of willing very much. And here is a list of contributing guide for you.
Your interest in contributing to Higress is warmly welcomed. First, we encourage this kind of willing very much. And here is a list of contributing guide for you.
[[中文贡献文档](./CONTRIBUTING_CN.md)]

195
CONTRIBUTING_JP.md Normal file
View File

@@ -0,0 +1,195 @@
# Higress への貢献
Higress のハッキングに興味がある場合は、温かく歓迎します。まず、このような意欲を非常に奨励します。そして、以下は貢献ガイドのリストです。
[[中文](./CONTRIBUTING.md)] | [[English Contributing Document](./CONTRIBUTING_EN.md)]
## トピック
- [Higress への貢献](#higress-への貢献)
- [トピック](#トピック)
- [セキュリティ問題の報告](#セキュリティ問題の報告)
- [一般的な問題の報告](#一般的な問題の報告)
- [コードとドキュメントの貢献](#コードとドキュメントの貢献)
- [ワークスペースの準備](#ワークスペースの準備)
- [ブランチの定義](#ブランチの定義)
- [コミットルール](#コミットルール)
- [コミットメッセージ](#コミットメッセージ)
- [コミット内容](#コミット内容)
- [PR 説明](#pr-説明)
- [テストケースの貢献](#テストケースの貢献)
- [何かを手伝うための参加](#何かを手伝うための参加)
- [コードスタイル](#コードスタイル)
## セキュリティ問題の報告
セキュリティ問題は常に真剣に扱われます。通常の原則として、セキュリティ問題を広めることは推奨しません。Higress のセキュリティ問題を発見した場合は、公開で議論せず、公開の問題を開かないでください。代わりに、[higress@googlegroups.com](mailto:higress@googlegroups.com) にプライベートなメールを送信して報告することをお勧めします。
## 一般的な問題の報告
正直なところ、Higress のすべてのユーザーを非常に親切な貢献者と見なしています。Higress を体験した後、プロジェクトに対するフィードバックがあるかもしれません。その場合は、[NEW ISSUE](https://github.com/alibaba/higress/issues/new/choose) を通じて問題を開くことを自由に行ってください。
Higress プロジェクトを分散型で協力しているため、**よく書かれた**、**詳細な**、**明確な**問題報告を高く評価します。コミュニケーションをより効率的にするために、問題が検索リストに存在するかどうかを検索することを希望します。存在する場合は、新しい問題を開くのではなく、既存の問題のコメントに詳細を追加してください。
問題の詳細をできるだけ標準化するために、問題報告者のために [ISSUE TEMPLATE](./.github/ISSUE_TEMPLATE) を設定しました。テンプレートのフィールドに従って指示に従って記入してください。
問題を開く場合は多くのケースがあります:
* バグ報告
* 機能要求
* パフォーマンス問題
* 機能提案
* 機能設計
* 助けが必要
* ドキュメントが不完全
* テストの改善
* プロジェクトに関する質問
* その他
また、新しい問題を記入する際には、投稿から機密データを削除することを忘れないでください。機密データには、パスワード、秘密鍵、ネットワークの場所、プライベートなビジネスデータなどが含まれる可能性があります。
## コードとドキュメントの貢献
Higress プロジェクトをより良くするためのすべての行動が奨励されます。GitHub では、Higress のすべての改善は PRプルリクエストの略を通じて行うことができます。
* タイプミスを見つけた場合は、修正してみてください!
* バグを見つけた場合は、修正してみてください!
* 冗長なコードを見つけた場合は、削除してみてください!
* 欠落しているテストケースを見つけた場合は、追加してみてください!
* 機能を強化できる場合は、**ためらわないでください**
* コードが不明瞭な場合は、コメントを追加して明確にしてください!
* コードが醜い場合は、リファクタリングしてみてください!
* ドキュメントの改善に役立つ場合は、さらに良いです!
* ドキュメントが不正確な場合は、修正してください!
* ...
実際には、それらを完全にリストすることは不可能です。1つの原則を覚えておいてください
> あなたからの PR を楽しみにしています。
Higress を PR で改善する準備ができたら、ここで PR ルールを確認することをお勧めします。
* [ワークスペースの準備](#ワークスペースの準備)
* [ブランチの定義](#ブランチの定義)
* [コミットルール](#コミットルール)
* [PR 説明](#pr-説明)
### ワークスペースの準備
PR を提出するために、GitHub ID に登録していることを前提とします。その後、以下の手順で準備を完了できます:
1. Higress を自分のリポジトリに **FORK** します。この作業を行うには、[alibaba/higress](https://github.com/alibaba/higress) のメインページの右上にある Fork ボタンをクリックするだけです。その後、`https://github.com/<your-username>/higress` に自分のリポジトリが作成されます。ここで、`your-username` はあなたの GitHub ユーザー名です。
2. 自分のリポジトリをローカルに **CLONE** します。`git clone git@github.com:<your-username>/higress.git` を使用してリポジトリをローカルマシンにクローンします。その後、新しいブランチを作成して、行いたい変更を完了できます。
3. リモートを `git@github.com:alibaba/higress.git` に設定します。以下の2つのコマンドを使用します
```bash
git remote add upstream git@github.com:alibaba/higress.git
git remote set-url --push upstream no-pushing
```
このリモート設定を使用すると、git リモート設定を次のように確認できます:
```shell
$ git remote -v
origin git@github.com:<your-username>/higress.git (fetch)
origin git@github.com:<your-username>/higress.git (push)
upstream git@github.com:alibaba/higress.git (fetch)
upstream no-pushing (push)
```
これを追加すると、ローカルブランチを上流ブランチと簡単に同期できます。
### ブランチの定義
現在、プルリクエストを通じたすべての貢献は Higress の [main ブランチ](https://github.com/alibaba/higress/tree/main) に対するものであると仮定します。貢献する前に、ブランチの定義を理解することは非常に役立ちます。
貢献者として、プルリクエストを通じたすべての貢献は main ブランチに対するものであることを再度覚えておいてください。Higress プロジェクトには、リリースブランチ0.6.0、0.6.1)、機能ブランチ、ホットフィックスブランチなど、いくつかの他のブランチがあります。
正式にバージョンをリリースする際には、リリースブランチが作成され、バージョン番号で命名されます。
リリース後、リリースブランチのコミットを main ブランチにマージします。
特定のバージョンにバグがある場合、後のバージョンで修正するか、特定のホットフィックスバージョンで修正するかを決定します。ホットフィックスバージョンで修正することを決定した場合、対応するリリースブランチに基づいてホットフィックスブランチをチェックアウトし、コード修正と検証を行い、main ブランチにマージします。
大きな機能については、開発と検証のために機能ブランチを引き出します。
### コミットルール
実際には、Higress ではコミット時に2つのルールを真剣に考えています
* [コミットメッセージ](#コミットメッセージ)
* [コミット内容](#コミット内容)
#### コミットメッセージ
コミットメッセージは、提出された PR の目的をレビュアーがよりよく理解するのに役立ちます。また、コードレビューの手続きを加速するのにも役立ちます。貢献者には、曖昧なメッセージではなく、**明確な**コミットメッセージを使用することを奨励します。一般的に、以下のコミットメッセージタイプを推奨します:
* docs: xxxx. 例:"docs: add docs about Higress cluster installation".
* feature: xxxx. 例:"feature: use higress config instead of istio config".
* bugfix: xxxx. 例:"bugfix: fix panic when input nil parameter".
* refactor: xxxx. 例:"refactor: simplify to make codes more readable".
* test: xxx. 例:"test: add unit test case for func InsertIntoArray".
* その他の読みやすく明確な表現方法。
一方で、以下のような方法でのコミットメッセージは推奨しません:
* ~~バグ修正~~
* ~~更新~~
* ~~ドキュメント追加~~
迷った場合は、[Git コミットメッセージの書き方](http://chris.beams.io/posts/git-commit/) を参照してください。
#### コミット内容
コミット内容は、1つのコミットに含まれるすべての内容の変更を表します。1つのコミットに、他のコミットの助けを借りずにレビュアーが完全にレビューできる内容を含めるのが最善です。言い換えれば、1つのコミットの内容は CI を通過でき、コードの混乱を避けることができます。簡単に言えば、次の3つの小さなルールを覚えておく必要があります
* コミットで非常に大きな変更を避ける;
* 各コミットが完全でレビュー可能であること。
* コミット時に git config`user.name``user.email`)を確認して、それが GitHub ID に関連付けられていることを確認します。
```bash
git config --get user.name
git config --get user.email
```
* pr を提出する際には、'changes/' フォルダーの下の XXX.md ファイルに現在の変更の簡単な説明を追加してください。
さらに、コード変更部分では、すべての貢献者が Higress の [コードスタイル](#コードスタイル) を読むことをお勧めします。
コミットメッセージやコミット内容に関係なく、コードレビューに重点を置いています。
### PR 説明
PR は Higress プロジェクトファイルを変更する唯一の方法です。レビュアーが目的をよりよく理解できるようにするために、PR 説明は詳細すぎることはありません。貢献者には、[PR テンプレート](./.github/PULL_REQUEST_TEMPLATE.md) に従ってプルリクエストを完了することを奨励します。
### 開発前の準備
```shell
make prebuild && go mod tidy
```
## テストケースの貢献
テストケースは歓迎されます。現在、Higress の機能テストケースが高優先度です。
* 単体テストの場合、同じモジュールの test ディレクトリに xxxTest.go という名前のテストファイルを作成する必要があります。
* 統合テストの場合、統合テストを test ディレクトリに配置できます。
//TBD
## 何かを手伝うための参加
GitHub を Higress の協力の主要な場所として選択しました。したがって、Higress の最新の更新は常にここにあります。PR を通じた貢献は明確な助けの方法ですが、他の方法も呼びかけています。
* 可能であれば、他の人の質問に返信する;
* 他のユーザーの問題を解決するのを手伝う;
* 他の人の PR 設計をレビューするのを手伝う;
* 他の人の PR のコードをレビューするのを手伝う;
* Higress について議論して、物事を明確にする;
* GitHub 以外で Higress 技術を宣伝する;
* Higress に関するブログを書くなど。
## コードスタイル
//TBD
要するに、**どんな助けも貢献です。**

View File

@@ -72,17 +72,17 @@ go.test.coverage: prebuild
.PHONY: build
build: prebuild $(OUT)
GOPROXY=$(GOPROXY) GOOS=$(GOOS_LOCAL) GOARCH=$(GOARCH_LOCAL) LDFLAGS=$(RELEASE_LDFLAGS) tools/hack/gobuild.sh $(OUT)/ $(HIGRESS_BINARIES)
GOPROXY="$(GOPROXY)" GOOS=$(GOOS_LOCAL) GOARCH=$(GOARCH_LOCAL) LDFLAGS=$(RELEASE_LDFLAGS) tools/hack/gobuild.sh $(OUT)/ $(HIGRESS_BINARIES)
.PHONY: build-linux
build-linux: prebuild $(OUT)
GOPROXY=$(GOPROXY) GOOS=linux GOARCH=$(GOARCH_LOCAL) LDFLAGS=$(RELEASE_LDFLAGS) tools/hack/gobuild.sh $(OUT_LINUX)/ $(HIGRESS_BINARIES)
GOPROXY="$(GOPROXY)" GOOS=linux GOARCH=$(GOARCH_LOCAL) LDFLAGS=$(RELEASE_LDFLAGS) tools/hack/gobuild.sh $(OUT_LINUX)/ $(HIGRESS_BINARIES)
$(AMD64_OUT_LINUX)/higress:
GOPROXY=$(GOPROXY) GOOS=linux GOARCH=amd64 LDFLAGS=$(RELEASE_LDFLAGS) tools/hack/gobuild.sh ./out/linux_amd64/ $(HIGRESS_BINARIES)
GOPROXY="$(GOPROXY)" GOOS=linux GOARCH=amd64 LDFLAGS=$(RELEASE_LDFLAGS) tools/hack/gobuild.sh ./out/linux_amd64/ $(HIGRESS_BINARIES)
$(ARM64_OUT_LINUX)/higress:
GOPROXY=$(GOPROXY) GOOS=linux GOARCH=arm64 LDFLAGS=$(RELEASE_LDFLAGS) tools/hack/gobuild.sh ./out/linux_arm64/ $(HIGRESS_BINARIES)
GOPROXY="$(GOPROXY)" GOOS=linux GOARCH=arm64 LDFLAGS=$(RELEASE_LDFLAGS) tools/hack/gobuild.sh ./out/linux_arm64/ $(HIGRESS_BINARIES)
.PHONY: build-hgctl
build-hgctl: prebuild $(OUT)
@@ -221,11 +221,15 @@ clean-higress: ## Cleans all the intermediate files and folders previously gener
rm -rf $(DIRS_TO_CLEAN)
clean-istio:
rm -rf external/api
rm -rf external/client-go
rm -rf external/istio
rm -rf external/pkg
clean-gateway: clean-istio
rm -rf external/envoy
rm -rf external/proxy
rm -rf external/go-control-plane
rm -rf external/package/envoy.tar.gz
clean-env:
@@ -284,6 +288,8 @@ delete-cluster: $(tools/kind) ## Delete kind cluster.
.PHONY: kube-load-image
kube-load-image: $(tools/kind) ## Install the Higress image to a kind cluster using the provided $IMAGE and $TAG.
tools/hack/kind-load-image.sh higress-registry.cn-hangzhou.cr.aliyuncs.com/higress/higress $(TAG)
tools/hack/docker-pull-image.sh higress-registry.cn-hangzhou.cr.aliyuncs.com/higress/pilot $(ISTIO_LATEST_IMAGE_TAG)
tools/hack/docker-pull-image.sh higress-registry.cn-hangzhou.cr.aliyuncs.com/higress/gateway $(ENVOY_LATEST_IMAGE_TAG)
tools/hack/docker-pull-image.sh higress-registry.cn-hangzhou.cr.aliyuncs.com/higress/dubbo-provider-demo 0.0.3-x86
tools/hack/docker-pull-image.sh higress-registry.cn-hangzhou.cr.aliyuncs.com/higress/nacos-standlone-rc3 1.0.0-RC3
tools/hack/docker-pull-image.sh docker.io/hashicorp/consul 1.16.0
@@ -294,6 +300,7 @@ kube-load-image: $(tools/kind) ## Install the Higress image to a kind cluster us
tools/hack/docker-pull-image.sh higress-registry.cn-hangzhou.cr.aliyuncs.com/higress/echo-server v1.0
tools/hack/docker-pull-image.sh higress-registry.cn-hangzhou.cr.aliyuncs.com/higress/echo-body 1.0.0
tools/hack/docker-pull-image.sh openpolicyagent/opa latest
tools/hack/docker-pull-image.sh curlimages/curl latest
tools/hack/docker-pull-image.sh registry.cn-hangzhou.aliyuncs.com/2456868764/httpbin 1.0.2
tools/hack/docker-pull-image.sh registry.cn-hangzhou.aliyuncs.com/hinsteny/nacos-standlone-rc3 1.0.0-RC3
tools/hack/kind-load-image.sh higress-registry.cn-hangzhou.cr.aliyuncs.com/higress/dubbo-provider-demo 0.0.3-x86
@@ -306,6 +313,7 @@ kube-load-image: $(tools/kind) ## Install the Higress image to a kind cluster us
tools/hack/kind-load-image.sh higress-registry.cn-hangzhou.cr.aliyuncs.com/higress/echo-server v1.0
tools/hack/kind-load-image.sh higress-registry.cn-hangzhou.cr.aliyuncs.com/higress/echo-body 1.0.0
tools/hack/kind-load-image.sh openpolicyagent/opa latest
tools/hack/kind-load-image.sh curlimages/curl latest
tools/hack/kind-load-image.sh registry.cn-hangzhou.aliyuncs.com/2456868764/httpbin 1.0.2
tools/hack/kind-load-image.sh registry.cn-hangzhou.aliyuncs.com/hinsteny/nacos-standlone-rc3 1.0.0-RC3

View File

@@ -1,3 +1,4 @@
<a name="readme-top"></a>
<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>
@@ -9,7 +10,7 @@
[![license](https://img.shields.io/github/license/alibaba/higress.svg)](https://www.apache.org/licenses/LICENSE-2.0.html)
[**官网**](https://higress.cn/) &nbsp; |
&nbsp; [**文档**](https://higress.cn/docs/latest/user/quickstart/) &nbsp; |
&nbsp; [**文档**](https://higress.cn/docs/latest/overview/what-is-higress/) &nbsp; |
&nbsp; [**博客**](https://higress.cn/blog/) &nbsp; |
&nbsp; [**电子书**](https://higress.cn/docs/ebook/wasm14/) &nbsp; |
&nbsp; [**开发指引**](https://higress.cn/docs/latest/dev/architecture/) &nbsp; |
@@ -17,18 +18,19 @@
<p>
<a href="README_EN.md"> English <a/> | 中文
<a href="README_EN.md"> English <a/>| 中文 | <a href="README_JP.md"> 日本語 <a/>
</p>
Higress 是基于阿里内部多年的 Envoy Gateway 实践沉淀,以开源 [Istio](https://github.com/istio/istio) 与 [Envoy](https://github.com/envoyproxy/envoy) 为核心构建的云原生 API 网关。
Higress 是一款云原生 API 网关,内核基于 Istio 和 Envoy可以用 Go/Rust/JS 等编写 Wasm 插件提供了数十个现成的通用插件以及开箱即用的控制台demo 点[这里](http://demo.higress.io/)
Higress 在阿里内部作为 AI 网关,承载了通义千问 APP、百炼大模型 API、机器学习 PAI 平台等 AI 业务的流量
Higress 在阿里内部为解决 Tengine reload 对长连接业务有损,以及 gRPC/Dubbo 负载均衡能力不足而诞生
Higress 能够用统一的协议对接国内外所有 LLM 模型厂商,同时具备丰富的 AI 可观测、多模型负载均衡/fallback、AI token 流控、AI 缓存等能力
阿里云基于 Higress 构建了云原生 API 网关产品,为大量企业客户提供 99.99% 的网关高可用保障服务能力
![](https://img.alicdn.com/imgextra/i1/O1CN01fNnhCp1cV8mYPRFeS_!!6000000003605-0-tps-1080-608.jpg)
Higress 基于 AI 网关能力,支撑了通义千问 APP、百炼大模型 API、机器学习 PAI 平台等 AI 业务。同时服务国内头部的 AIGC 企业(如零一万物),以及 AI 产品(如 FastGPT
![](https://img.alicdn.com/imgextra/i2/O1CN011AbR8023V8R5N0HcA_!!6000000007260-2-tps-1080-606.png)
## Summary
@@ -58,7 +60,7 @@ docker run -d --rm --name higress-ai -v ${PWD}:/data \
- 8080 端口:网关 HTTP 协议入口
- 8443 端口:网关 HTTPS 协议入口
**Higress 的所有 Docker 镜像都一直使用自己独享的仓库,不受 Docker Hub 境内不可访问的影响**
**Higress 的所有 Docker 镜像都一直使用自己独享的仓库,不受 Docker Hub 境内访问受限的影响**
K8s 下使用 Helm 部署等其他安装方式可以参考官网 [Quick Start 文档](https://higress.cn/docs/latest/user/quickstart/)。
@@ -67,23 +69,32 @@ K8s 下使用 Helm 部署等其他安装方式可以参考官网 [Quick Start
- **AI 网关**:
Higress 提供了一站式的 AI 插件集,可以增强依赖 AI 能力业务的稳定性、灵活性、可观测性,使得业务与 AI 的集成更加便捷和高效。
Higress 能够用统一的协议对接国内外所有 LLM 模型厂商,同时具备丰富的 AI 可观测、多模型负载均衡/fallback、AI token 流控、AI 缓存等能力:
![](https://img.alicdn.com/imgextra/i1/O1CN01fNnhCp1cV8mYPRFeS_!!6000000003605-0-tps-1080-608.jpg)
- **Kubernetes Ingress 网关**:
Higress 可以作为 K8s 集群的 Ingress 入口网关, 并且兼容了大量 K8s Nginx Ingress 的注解,可以从 K8s Nginx Ingress 快速平滑迁移到 Higress。
支持 [Gateway API](https://gateway-api.sigs.k8s.io/) 标准,支持用户从 Ingress API 平滑迁移到 Gateway API。
相比 ingress-nginx资源开销大幅下降路由变更生效速度有十倍提升
![](https://img.alicdn.com/imgextra/i1/O1CN01bhEtb229eeMNBWmdP_!!6000000008093-2-tps-750-547.png)
![](https://img.alicdn.com/imgextra/i1/O1CN01bqRets1LsBGyitj4S_!!6000000001354-2-tps-887-489.png)
- **微服务网关**:
Higress 可以作为微服务网关, 能够对接多种类型的注册中心发现服务配置路由,例如 Nacos, ZooKeeper, Consul, Eureka 等。
并且深度集成了 [Dubbo](https://github.com/apache/dubbo), [Nacos](https://github.com/alibaba/nacos), [Sentinel](https://github.com/alibaba/Sentinel) 等微服务技术栈,基于 Envoy C++ 网关内核的出色性能,相比传统 Java 类微服务网关,可以显著降低资源使用率,减少成本。
![](https://img.alicdn.com/imgextra/i4/O1CN01v4ZbCj1dBjePSMZ17_!!6000000003698-0-tps-1613-926.jpg)
- **安全防护网关**:
Higress 可以作为安全防护网关, 提供 WAF 的能力,并且支持多种认证鉴权策略,例如 key-auth, hmac-auth, jwt-auth, basic-auth, oidc 等。
Higress 可以作为安全防护网关, 提供 WAF 的能力,并且支持多种认证鉴权策略,例如 key-auth, hmac-auth, jwt-auth, basic-auth, oidc 等。
## 核心优势
@@ -165,7 +176,7 @@ K8s 下使用 Helm 部署等其他安装方式可以参考官网 [Quick Start
### 交流群
![image](https://img.alicdn.com/imgextra/i2/O1CN01qPd7Ix1uZPVEsWjWp_!!6000000006051-0-tps-720-405.jpg)
![image](https://img.alicdn.com/imgextra/i2/O1CN01BkopaB22ZsvamFftE_!!6000000007135-0-tps-720-405.jpg)
### 技术分享
@@ -176,4 +187,20 @@ K8s 下使用 Helm 部署等其他安装方式可以参考官网 [Quick Start
### 关联仓库
- Higress 控制台https://github.com/higress-group/higress-console
- Higress独立运行版https://github.com/higress-group/higress-standalone
- Higress独立运行版https://github.com/higress-group/higress-standalone
### 贡献者
<a href="https://github.com/alibaba/higress/graphs/contributors">
<img alt="contributors" src="https://contrib.rocks/image?repo=alibaba/higress"/>
</a>
### Star History
[![Star History](https://api.star-history.com/svg?repos=alibaba/higress&type=Date)](https://star-history.com/#alibaba/higress&Date)
<p align="right" style="font-size: 14px; color: #555; margin-top: 20px;">
<a href="#readme-top" style="text-decoration: none; color: #007bff; font-weight: bold;">
↑ 返回顶部 ↑
</a>
</p>

View File

@@ -1,3 +1,4 @@
<a name="readme-top"></a>
<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>
@@ -15,7 +16,7 @@
<p>
English | <a href="README.md">中文<a/>
English | <a href="README.md">中文<a/> | <a href="README_JP.md">日本語<a/>
</p>
Higress is a cloud-native api gateway based on Alibaba's internal gateway practices.
@@ -47,7 +48,7 @@ Powered by [Istio](https://github.com/istio/istio) and [Envoy](https://github.co
Higress can function as a microservice gateway, which can discovery microservices from various service registries, such as Nacos, ZooKeeper, Consul, Eureka, etc.
It deeply integrates of [Dubbo](https://github.com/apache/dubbo), [Nacos](https://github.com/alibaba/nacos), [Sentinel](https://github.com/alibaba/Sentinel) and other microservice technology stacks.
It deeply integrates with [Dubbo](https://github.com/apache/dubbo), [Nacos](https://github.com/alibaba/nacos), [Sentinel](https://github.com/alibaba/Sentinel) and other microservice technology stacks.
- **Security gateway**:
@@ -57,7 +58,7 @@ Powered by [Istio](https://github.com/istio/istio) and [Envoy](https://github.co
- **Easy to use**
Provide one-stop gateway solutions for traffic scheduling, service management, and security protection, support Console, K8s Ingress, and Gateway API configuration methods, and also support HTTP to Dubbo protocol conversion, and easily complete protocol mapping configuration.
Provides one-stop gateway solutions for traffic scheduling, service management, and security protection, support Console, K8s Ingress, and Gateway API configuration methods, and also support HTTP to Dubbo protocol conversion, and easily complete protocol mapping configuration.
- **Easy to expand**
@@ -73,7 +74,7 @@ Powered by [Istio](https://github.com/istio/istio) and [Envoy](https://github.co
- **Security**
Provides JWT, OIDC, custom authentication and authentication, deeply integrates open source web application firewall.
Provides JWT, OIDC, custom authentication and authentication, deeply integrates open-source web application firewall.
## Community
@@ -81,9 +82,25 @@ Powered by [Istio](https://github.com/istio/istio) and [Envoy](https://github.co
### Thanks
Higress would not be possible without the valuable open-source work of projects in the community. We would like to extend a special thank-you to Envoy and Istio.
Higress would not be possible without the valuable open-source work of projects in the community. We would like to extend a special thank you to Envoy and Istio.
### Related Repositories
- Higress Console: https://github.com/higress-group/higress-console
- Higress Standalone: https://github.com/higress-group/higress-standalone
- Higress Standalone: https://github.com/higress-group/higress-standalone
### Contributors
<a href="https://github.com/alibaba/higress/graphs/contributors">
<img alt="contributors" src="https://contrib.rocks/image?repo=alibaba/higress"/>
</a>
### Star History
[![Star History Chart](https://api.star-history.com/svg?repos=alibaba/higress&type=Date)](https://star-history.com/#alibaba/higress&Date)
<p align="right" style="font-size: 14px; color: #555; margin-top: 20px;">
<a href="#readme-top" style="text-decoration: none; color: #007bff; font-weight: bold;">
↑ Back to Top ↑
</a>
</p>

206
README_JP.md Normal file
View File

@@ -0,0 +1,206 @@
<a name="readme-top"></a>
<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ゲートウェイ
</h1>
<h4 align="center"> AIネイティブAPIゲートウェイ </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)
[**公式サイト**](https://higress.cn/) &nbsp; |
&nbsp; [**ドキュメント**](https://higress.cn/docs/latest/overview/what-is-higress/) &nbsp; |
&nbsp; [**ブログ**](https://higress.cn/blog/) &nbsp; |
&nbsp; [**電子書籍**](https://higress.cn/docs/ebook/wasm14/) &nbsp; |
&nbsp; [**開発ガイド**](https://higress.cn/docs/latest/dev/architecture/) &nbsp; |
&nbsp; [**AIプラグイン**](https://higress.cn/plugin/) &nbsp;
<p>
<a href="README_EN.md"> English <a/> | <a href="README.md">中文<a/> | 日本語
</p>
Higressは、IstioとEnvoyをベースにしたクラウドネイティブAPIゲートウェイで、Go/Rust/JSなどを使用してWasmプラグインを作成できます。数十の既製の汎用プラグインと、すぐに使用できるコンソールを提供していますデモは[こちら](http://demo.higress.io/))。
Higressは、Tengineのリロードが長時間接続のビジネスに影響を与える問題や、gRPC/Dubboの負荷分散能力の不足を解決するために、Alibaba内部で誕生しました。
Alibaba Cloudは、Higressを基盤にクラウドネイティブAPIゲートウェイ製品を構築し、多くの企業顧客に99.99%のゲートウェイ高可用性保証サービスを提供しています。
Higressは、AIゲートウェイ機能を基盤に、Tongyi Qianwen APP、Bailian大規模モデルAPI、機械学習PAIプラットフォームなどのAIビジネスをサポートしています。また、国内の主要なAIGC企業ZeroOneやAI製品FastGPTにもサービスを提供しています。
![](https://img.alicdn.com/imgextra/i2/O1CN011AbR8023V8R5N0HcA_!!6000000007260-2-tps-1080-606.png)
## 目次
- [**クイックスタート**](#クイックスタート)
- [**機能紹介**](#機能紹介)
- [**使用シナリオ**](#使用シナリオ)
- [**主な利点**](#主な利点)
- [**コミュニティ**](#コミュニティ)
## クイックスタート
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デプロイなどの他のインストール方法については、公式サイトの[クイックスタートドキュメント](https://higress.cn/docs/latest/user/quickstart/)を参照してください。
## 使用シナリオ
- **AIゲートウェイ**:
Higressは、国内外のすべてのLLMモデルプロバイダーと統一されたプロトコルで接続でき、豊富なAI可観測性、多モデル負荷分散/フォールバック、AIトークンフロー制御、AIキャッシュなどの機能を備えています。
![](https://img.alicdn.com/imgextra/i1/O1CN01fNnhCp1cV8mYPRFeS_!!6000000003605-0-tps-1080-608.jpg)
- **Kubernetes Ingressゲートウェイ**:
HigressはK8sクラスターのIngressエントリーポイントゲートウェイとして機能し、多くのK8s Nginx Ingressの注釈に対応しています。K8s Nginx IngressからHigressへのスムーズな移行が可能です。
[Gateway API](https://gateway-api.sigs.k8s.io/)標準をサポートし、ユーザーがIngress APIからGateway APIにスムーズに移行できるようにします。
ingress-nginxと比較して、リソースの消費が大幅に減少し、ルーティングの変更が10倍速く反映されます。
![](https://img.alicdn.com/imgextra/i1/O1CN01bhEtb229eeMNBWmdP_!!6000000008093-2-tps-750-547.png)
![](https://img.alicdn.com/imgextra/i1/O1CN01bqRets1LsBGyitj4S_!!6000000001354-2-tps-887-489.png)
- **マイクロサービスゲートウェイ**:
Higressはマイクロサービスゲートウェイとして機能し、Nacos、ZooKeeper、Consul、Eurekaなどのさまざまなサービスレジストリからサービスを発見し、ルーティングを構成できます。
また、[Dubbo](https://github.com/apache/dubbo)、[Nacos](https://github.com/alibaba/nacos)、[Sentinel](https://github.com/alibaba/Sentinel)などのマイクロサービス技術スタックと深く統合されています。Envoy C++ゲートウェイコアの優れたパフォーマンスに基づいて、従来のJavaベースのマイクロサービスゲートウェイと比較して、リソース使用率を大幅に削減し、コストを削減できます。
![](https://img.alicdn.com/imgextra/i4/O1CN01v4ZbCj1dBjePSMZ17_!!6000000003698-0-tps-1613-926.jpg)
- **セキュリティゲートウェイ**:
Higressはセキュリティゲートウェイとして機能し、WAF機能を提供し、key-auth、hmac-auth、jwt-auth、basic-auth、oidcなどのさまざまな認証戦略をサポートします。
## 主な利点
- **プロダクションレベル**
Alibabaで2年以上のプロダクション検証を経た内部製品から派生し、毎秒数十万のリクエストを処理する大規模なシナリオをサポートします。
Nginxのリロードによるトラフィックの揺れを完全に排除し、構成変更がミリ秒単位で反映され、ビジネスに影響を与えません。AIビジネスなどの長時間接続シナリオに特に適しています。
- **ストリーム処理**
リクエスト/レスポンスボディの完全なストリーム処理をサポートし、Wasmプラグインを使用してSSEServer-Sent Eventsなどのストリームプロトコルのメッセージをカスタマイズして処理できます。
AIビジネスなどの大帯域幅シナリオで、メモリ使用量を大幅に削減できます。
- **拡張性**
AI、トラフィック管理、セキュリティ保護などの一般的な機能をカバーする豊富な公式プラグインライブラリを提供し、90以上のビジネスシナリオのニーズを満たします。
Wasmプラグイン拡張を主力とし、サンドボックス隔離を通じてメモリの安全性を確保し、複数のプログラミング言語をサポートし、プラグインバージョンの独立したアップグレードを許可し、トラフィックに影響を与えずにゲートウェイロジックをホットアップデートできます。
- **安全で使いやすい**
Ingress APIおよびGateway API標準に基づき、すぐに使用できるUIコンソールを提供し、WAF保護プラグイン、IP/Cookie CC保護プラグインをすぐに使用できます。
Let's Encryptの自動証明書発行および更新をサポートし、K8sを使用せずにデプロイでき、1行のDockerコマンドで起動でき、個人開発者にとって便利です。
## 機能紹介
### AIゲートウェイデモ展示
[OpenAIから他の大規模モデルへの移行を30秒で完了
](https://www.bilibili.com/video/BV1dT421a7w7/?spm_id_from=333.788.recommend_more_video.14)
### Higress UIコンソール
- **豊富な可観測性**
すぐに使用できる可観測性を提供し、GrafanaPrometheusは組み込みのものを使用することも、自分で構築したものを接続することもできます。
![](./docs/images/monitor.gif)
- **プラグイン拡張メカニズム**
公式にはさまざまなプラグインが提供されており、ユーザーは[独自のプラグインを開発](./plugins/wasm-go)し、Docker/OCIイメージとして構築し、コンソールで構成して、プラグインロジックをリアルタイムで変更できます。トラフィックに影響を与えずにプラグインロジックをホットアップデートできます。
![](./docs/images/plugin.gif)
- **さまざまなサービス発見**
デフォルトでK8s Serviceサービス発見を提供し、構成を通じてNacos/ZooKeeperなどのレジストリに接続してサービスを発見することも、静的IPまたはDNSに基づいて発見することもできます。
![](./docs/images/service-source.gif)
- **ドメインと証明書**
TLS証明書を作成および管理し、ドメインのHTTP/HTTPS動作を構成できます。ドメインポリシーでは、特定のドメインに対してプラグインを適用することができます。
![](./docs/images/domain.gif)
- **豊富なルーティング機能**
上記で定義されたサービス発見メカニズムを通じて、発見されたサービスはサービスリストに表示されます。ルーティングを作成する際に、ドメインを選択し、ルーティングマッチングメカニズムを定義し、ターゲットサービスを選択してルーティングを行います。ルーティングポリシーでは、特定のルーティングに対してプラグインを適用することができます。
![](./docs/images/route-service.gif)
## コミュニティ
### 感謝
EnvoyとIstioのオープンソースの取り組みがなければ、Higressは実現できませんでした。これらのプロジェクトに最も誠実な敬意を表します。
### 交流グループ
![image](https://img.alicdn.com/imgextra/i2/O1CN01BkopaB22ZsvamFftE_!!6000000007135-0-tps-720-405.jpg)
### 技術共有
WeChat公式アカウント
![](https://img.alicdn.com/imgextra/i1/O1CN01WnQt0q1tcmqVDU73u_!!6000000005923-0-tps-258-258.jpg)
### 関連リポジトリ
- Higressコンソールhttps://github.com/higress-group/higress-console
- Higressスタンドアロン版https://github.com/higress-group/higress-standalone
### 貢献者
<a href="https://github.com/alibaba/higress/graphs/contributors">
<img alt="contributors" src="https://contrib.rocks/image?repo=alibaba/higress"/>
</a>
### スターの歴史
[![スターの歴史チャート](https://api.star-history.com/svg?repos=alibaba/higress&type=Date)](https://star-history.com/#alibaba/higress&Date)
<p align="right" style="font-size: 14px; color: #555; margin-top: 20px;">
<a href="#readme-top" style="text-decoration: none; color: #007bff; font-weight: bold;">
↑ トップに戻る ↑
</a>
</p>

View File

@@ -4,6 +4,7 @@
| Version | Supported |
| ------- | ------------------ |
| 2.x.x | :white_check_mark: |
| 1.x.x | :white_check_mark: |
| < 1.0.0 | :x: |

View File

@@ -1 +1 @@
v2.0.1
v2.0.3

View File

@@ -1,5 +1,5 @@
apiVersion: v2
appVersion: 2.0.1
appVersion: 2.0.3
description: Helm chart for deploying higress gateways
icon: https://higress.io/img/higress_logo_small.png
home: http://higress.io/
@@ -10,4 +10,4 @@ name: higress-core
sources:
- http://github.com/alibaba/higress
type: application
version: 2.0.1
version: 2.0.3

View File

@@ -1,3 +1,4 @@
---
apiVersion: apiextensions.k8s.io/v1
kind: CustomResourceDefinition
metadata:

View File

@@ -116,6 +116,12 @@ data:
{{- $existingData = index $existingConfig.data "higress" | default "{}" | fromYaml }}
{{- end }}
{{- $newData := dict }}
{{- if hasKey .Values "upstream" }}
{{- $_ := set $newData "upstream" .Values.upstream }}
{{- end }}
{{- if hasKey .Values "downstream" }}
{{- $_ := set $newData "downstream" .Values.downstream }}
{{- end }}
{{- if and (hasKey .Values "tracing") .Values.tracing.enable }}
{{- $_ := set $newData "tracing" .Values.tracing }}
{{- end }}

View File

@@ -129,3 +129,10 @@ rules:
- apiGroups: ["networking.internal.knative.dev"]
resources: ["ingresses/status"]
verbs: ["get","patch","update"]
# gateway api need
- apiGroups: ["apps"]
verbs: [ "get", "watch", "list", "update", "patch", "create", "delete" ]
resources: [ "deployments" ]
- apiGroups: [""]
verbs: [ "get", "watch", "list", "update", "patch", "create", "delete" ]
resources: [ "serviceaccounts"]

View File

@@ -69,6 +69,12 @@ spec:
fieldPath: spec.serviceAccountName
- name: DOMAIN_SUFFIX
value: {{ .Values.global.proxy.clusterDomain }}
- name: GATEWAY_NAME
value: {{ include "gateway.name" . }}
- name: PILOT_ENABLE_GATEWAY_API
value: "{{ .Values.global.enableGatewayAPI }}"
- name: PILOT_ENABLE_ALPHA_GATEWAY_API
value: "{{ .Values.global.enableGatewayAPI }}"
{{- if .Values.controller.env }}
{{- range $key, $val := .Values.controller.env }}
- name: {{ $key }}
@@ -215,14 +221,14 @@ spec:
- name: HIGRESS_ENABLE_ISTIO_API
value: "true"
{{- end }}
{{- if .Values.global.enableGatewayAPI }}
- name: PILOT_ENABLE_GATEWAY_API
value: "true"
value: "false"
- name: PILOT_ENABLE_ALPHA_GATEWAY_API
value: "false"
- name: PILOT_ENABLE_GATEWAY_API_STATUS
value: "true"
value: "false"
- name: PILOT_ENABLE_GATEWAY_API_DEPLOYMENT_CONTROLLER
value: "false"
{{- end }}
{{- if not .Values.global.enableHigressIstio }}
- name: CUSTOM_CA_CERT_NAME
value: "higress-ca-root-cert"

View File

@@ -0,0 +1,22 @@
apiVersion: networking.istio.io/v1alpha3
kind: EnvoyFilter
metadata:
name: {{ include "gateway.name" . }}-global-custom-response
namespace: {{ .Release.Namespace }}
labels:
{{- include "gateway.labels" . | nindent 4}}
spec:
configPatches:
- applyTo: HTTP_FILTER
match:
context: GATEWAY
listener:
filterChain:
filter:
name: envoy.filters.network.http_connection_manager
patch:
operation: INSERT_FIRST
value:
name: envoy.filters.http.custom_response
typed_config:
"@type": type.googleapis.com/envoy.extensions.filters.http.custom_response.v3.CustomResponse

View File

@@ -1,6 +1,8 @@
{{- if .Values.global.ingressClass }}
apiVersion: networking.k8s.io/v1
kind: IngressClass
metadata:
name: {{ .Values.global.ingressClass }}
spec:
controller: higress.io/higress-controller
controller: higress.io/higress-controller
{{- end }}

View File

@@ -26,7 +26,7 @@ global:
autoscalingv2API: true
local: false # When deploying to a local cluster (e.g.: kind cluster), set this to true.
kind: false # Deprecated. Please use "global.local" instead. Will be removed later.
enableIstioAPI: false
enableIstioAPI: true
enableGatewayAPI: false
# Deprecated
enableHigressIstio: false
@@ -684,3 +684,19 @@ tracing:
# zipkin:
# service: ""
# port: 9411
# Downstream config settings
downstream:
idleTimeout: 180
maxRequestHeadersKb: 60
connectionBufferLimits: 32768
http2:
maxConcurrentStreams: 100
initialStreamWindowSize: 65535
initialConnectionWindowSize: 1048576
routeTimeout: 0
# Upstream config settings
upstream:
idleTimeout: 10
connectionBufferLimits: 10485760

View File

@@ -1,9 +1,9 @@
dependencies:
- name: higress-core
repository: file://../core
version: 2.0.1
version: 2.0.3
- name: higress-console
repository: https://higress.io/helm-charts/
version: 1.4.4
digest: sha256:6e4d77c31c834a404a728ec5a8379dd5df27a7e9b998a08e6524dc6534b07c1d
generated: "2024-10-09T20:07:21.857942+08:00"
version: 1.4.5
digest: sha256:74b772113264168483961f5d0424459fd7359adc509a4b50400229581d7cddbf
generated: "2024-11-08T14:06:51.871719+08:00"

View File

@@ -1,5 +1,5 @@
apiVersion: v2
appVersion: 2.0.1
appVersion: 2.0.3
description: Helm chart for deploying Higress gateways
icon: https://higress.io/img/higress_logo_small.png
home: http://higress.io/
@@ -12,9 +12,9 @@ sources:
dependencies:
- name: higress-core
repository: "file://../core"
version: 2.0.1
version: 2.0.3
- name: higress-console
repository: "https://higress.io/helm-charts/"
version: 1.4.4
version: 1.4.5
type: application
version: 2.0.1
version: 2.0.3

View File

@@ -19,6 +19,7 @@ import "istio.io/pkg/env"
var (
PodNamespace = env.RegisterStringVar("POD_NAMESPACE", "higress-system", "").Get()
PodName = env.RegisterStringVar("POD_NAME", "", "").Get()
GatewayName = env.RegisterStringVar("GATEWAY_NAME", "higress-gateway", "").Get()
// Revision is the value of the Istio control plane revision, e.g. "canary",
// and is the value used by the "istio.io/rev" label.
Revision = env.Register("REVISION", "", "").Get()

View File

@@ -34,6 +34,7 @@ import (
extensions "istio.io/api/extensions/v1alpha1"
networking "istio.io/api/networking/v1alpha3"
istiotype "istio.io/api/type/v1beta1"
"istio.io/istio/pilot/pkg/features"
istiomodel "istio.io/istio/pilot/pkg/model"
"istio.io/istio/pilot/pkg/util/protoconv"
"istio.io/istio/pkg/cluster"
@@ -235,8 +236,9 @@ func (m *IngressConfig) AddLocalCluster(options common.Options) {
ingressController = ingressv1.NewController(m.localKubeClient, m.localKubeClient, options, secretController)
}
m.remoteIngressControllers[options.ClusterId] = ingressController
m.remoteGatewayControllers[options.ClusterId] = gateway.NewController(m.localKubeClient, options)
if features.EnableGatewayAPI {
m.remoteGatewayControllers[options.ClusterId] = gateway.NewController(m.localKubeClient, options)
}
}
func (m *IngressConfig) List(typ config.GroupVersionKind, namespace string) []config.Config {
@@ -719,9 +721,9 @@ func (m *IngressConfig) convertDestinationRule(configs []common.WrapperConfig) [
} else if dr.DestinationRule.TrafficPolicy != nil {
portTrafficPolicy := destinationRuleWrapper.DestinationRule.TrafficPolicy.PortLevelSettings[0]
portUpdated := false
for _, portTrafficPolicy := range dr.DestinationRule.TrafficPolicy.PortLevelSettings {
if portTrafficPolicy.Port.Number == portTrafficPolicy.Port.Number {
portTrafficPolicy.Tls = portTrafficPolicy.Tls
for _, policy := range dr.DestinationRule.TrafficPolicy.PortLevelSettings {
if policy.Port.Number == portTrafficPolicy.Port.Number {
policy.Tls = portTrafficPolicy.Tls
portUpdated = true
break
}

View File

@@ -255,6 +255,59 @@ func (t *TracingController) ConstructEnvoyFilters() ([]*config.Config, error) {
return configs, nil
}
configPatches := []*networking.EnvoyFilter_EnvoyConfigObjectPatch{
{
ApplyTo: networking.EnvoyFilter_NETWORK_FILTER,
Match: &networking.EnvoyFilter_EnvoyConfigObjectMatch{
Context: networking.EnvoyFilter_GATEWAY,
ObjectTypes: &networking.EnvoyFilter_EnvoyConfigObjectMatch_Listener{
Listener: &networking.EnvoyFilter_ListenerMatch{
FilterChain: &networking.EnvoyFilter_ListenerMatch_FilterChainMatch{
Filter: &networking.EnvoyFilter_ListenerMatch_FilterMatch{
Name: "envoy.filters.network.http_connection_manager",
},
},
},
},
},
Patch: &networking.EnvoyFilter_Patch{
Operation: networking.EnvoyFilter_Patch_MERGE,
Value: util.BuildPatchStruct(tracingConfig),
},
},
{
ApplyTo: networking.EnvoyFilter_HTTP_FILTER,
Match: &networking.EnvoyFilter_EnvoyConfigObjectMatch{
Context: networking.EnvoyFilter_GATEWAY,
ObjectTypes: &networking.EnvoyFilter_EnvoyConfigObjectMatch_Listener{
Listener: &networking.EnvoyFilter_ListenerMatch{
FilterChain: &networking.EnvoyFilter_ListenerMatch_FilterChainMatch{
Filter: &networking.EnvoyFilter_ListenerMatch_FilterMatch{
Name: "envoy.filters.network.http_connection_manager",
SubFilter: &networking.EnvoyFilter_ListenerMatch_SubFilterMatch{
Name: "envoy.filters.http.router",
},
},
},
},
},
},
Patch: &networking.EnvoyFilter_Patch{
Operation: networking.EnvoyFilter_Patch_MERGE,
Value: util.BuildPatchStruct(`{
"name":"envoy.filters.http.router",
"typed_config":{
"@type": "type.googleapis.com/envoy.extensions.filters.http.router.v3.Router",
"start_child_span": true
}
}`),
},
},
}
patches := t.constructTracingExtendPatches(tracing)
configPatches = append(configPatches, patches...)
config := &config.Config{
Meta: config.Meta{
GroupVersionKind: gvk.EnvoyFilter,
@@ -262,55 +315,7 @@ func (t *TracingController) ConstructEnvoyFilters() ([]*config.Config, error) {
Namespace: namespace,
},
Spec: &networking.EnvoyFilter{
ConfigPatches: []*networking.EnvoyFilter_EnvoyConfigObjectPatch{
{
ApplyTo: networking.EnvoyFilter_NETWORK_FILTER,
Match: &networking.EnvoyFilter_EnvoyConfigObjectMatch{
Context: networking.EnvoyFilter_GATEWAY,
ObjectTypes: &networking.EnvoyFilter_EnvoyConfigObjectMatch_Listener{
Listener: &networking.EnvoyFilter_ListenerMatch{
FilterChain: &networking.EnvoyFilter_ListenerMatch_FilterChainMatch{
Filter: &networking.EnvoyFilter_ListenerMatch_FilterMatch{
Name: "envoy.filters.network.http_connection_manager",
},
},
},
},
},
Patch: &networking.EnvoyFilter_Patch{
Operation: networking.EnvoyFilter_Patch_MERGE,
Value: util.BuildPatchStruct(tracingConfig),
},
},
{
ApplyTo: networking.EnvoyFilter_HTTP_FILTER,
Match: &networking.EnvoyFilter_EnvoyConfigObjectMatch{
Context: networking.EnvoyFilter_GATEWAY,
ObjectTypes: &networking.EnvoyFilter_EnvoyConfigObjectMatch_Listener{
Listener: &networking.EnvoyFilter_ListenerMatch{
FilterChain: &networking.EnvoyFilter_ListenerMatch_FilterChainMatch{
Filter: &networking.EnvoyFilter_ListenerMatch_FilterMatch{
Name: "envoy.filters.network.http_connection_manager",
SubFilter: &networking.EnvoyFilter_ListenerMatch_SubFilterMatch{
Name: "envoy.filters.http.router",
},
},
},
},
},
},
Patch: &networking.EnvoyFilter_Patch{
Operation: networking.EnvoyFilter_Patch_MERGE,
Value: util.BuildPatchStruct(`{
"name":"envoy.filters.http.router",
"typed_config":{
"@type": "type.googleapis.com/envoy.extensions.filters.http.router.v3.Router",
"start_child_span": true
}
}`),
},
},
},
ConfigPatches: configPatches,
},
}
@@ -318,6 +323,52 @@ func (t *TracingController) ConstructEnvoyFilters() ([]*config.Config, error) {
return configs, nil
}
func tracingClusterName(port, service string) string {
return fmt.Sprintf("outbound|%s||%s", port, service)
}
func (t *TracingController) constructHTTP2ProtocolOptionsPatch(port, service string) *networking.EnvoyFilter_EnvoyConfigObjectPatch {
http2ProtocolOptions := `{"typed_extension_protocol_options": {
"envoy.extensions.upstreams.http.v3.HttpProtocolOptions": {
"@type": "type.googleapis.com/envoy.extensions.upstreams.http.v3.HttpProtocolOptions",
"explicit_http_config": {
"http2_protocol_options": {}
}
}
}}`
return &networking.EnvoyFilter_EnvoyConfigObjectPatch{
ApplyTo: networking.EnvoyFilter_CLUSTER,
Match: &networking.EnvoyFilter_EnvoyConfigObjectMatch{
Context: networking.EnvoyFilter_GATEWAY,
ObjectTypes: &networking.EnvoyFilter_EnvoyConfigObjectMatch_Cluster{
Cluster: &networking.EnvoyFilter_ClusterMatch{
Name: tracingClusterName(port, service),
},
},
},
Patch: &networking.EnvoyFilter_Patch{
Operation: networking.EnvoyFilter_Patch_MERGE,
Value: util.BuildPatchStruct(http2ProtocolOptions),
},
}
}
func (t *TracingController) constructTracingExtendPatches(tracing *Tracing) []*networking.EnvoyFilter_EnvoyConfigObjectPatch {
if tracing == nil {
return nil
}
var patches []*networking.EnvoyFilter_EnvoyConfigObjectPatch
if skywalking := tracing.Skywalking; skywalking != nil {
patches = append(patches, t.constructHTTP2ProtocolOptionsPatch(skywalking.Port, skywalking.Service))
}
if otel := tracing.OpenTelemetry; otel != nil {
patches = append(patches, t.constructHTTP2ProtocolOptionsPatch(otel.Port, otel.Service))
}
return patches
}
func (t *TracingController) constructTracingTracer(tracing *Tracing, namespace string) string {
tracingConfig := ""
timeout := float32(tracing.Timeout) / 1000
@@ -338,7 +389,7 @@ func (t *TracingController) constructTracingTracer(tracing *Tracing, namespace s
},
"grpc_service": {
"envoy_grpc": {
"cluster_name": "outbound|%s||%s"
"cluster_name": "%s"
},
"timeout": "%.3fs"
}
@@ -349,7 +400,7 @@ func (t *TracingController) constructTracingTracer(tracing *Tracing, namespace s
}
}
}
}`, namespace, skywalking.AccessToken, skywalking.Port, skywalking.Service, timeout, tracing.Sampling)
}`, namespace, skywalking.AccessToken, tracingClusterName(skywalking.Port, skywalking.Service), timeout, tracing.Sampling)
}
if tracing.Zipkin != nil {
@@ -363,7 +414,7 @@ func (t *TracingController) constructTracingTracer(tracing *Tracing, namespace s
"name": "envoy.tracers.zipkin",
"typed_config": {
"@type": "type.googleapis.com/envoy.config.trace.v3.ZipkinConfig",
"collector_cluster": "outbound|%s||%s",
"collector_cluster": "%s",
"collector_endpoint": "/api/v2/spans",
"collector_hostname": "higress-gateway",
"collector_endpoint_version": "HTTP_JSON",
@@ -375,7 +426,7 @@ func (t *TracingController) constructTracingTracer(tracing *Tracing, namespace s
}
}
}
}`, zipkin.Port, zipkin.Service, tracing.Sampling)
}`, tracingClusterName(zipkin.Port, zipkin.Service), tracing.Sampling)
}
if tracing.OpenTelemetry != nil {
@@ -392,7 +443,7 @@ func (t *TracingController) constructTracingTracer(tracing *Tracing, namespace s
"service_name": "higress-gateway.%s",
"grpc_service": {
"envoy_grpc": {
"cluster_name": "outbound|%s||%s"
"cluster_name": "%s"
},
"timeout": "%.3fs"
}
@@ -403,7 +454,7 @@ func (t *TracingController) constructTracingTracer(tracing *Tracing, namespace s
}
}
}
}`, namespace, opentelemetry.Port, opentelemetry.Service, timeout, tracing.Sampling)
}`, namespace, tracingClusterName(opentelemetry.Port, opentelemetry.Service), timeout, tracing.Sampling)
}
return tracingConfig
}

View File

@@ -25,6 +25,7 @@ import (
"strings"
higressconfig "github.com/alibaba/higress/pkg/config"
"github.com/alibaba/higress/pkg/ingress/kube/util"
istio "istio.io/api/networking/v1alpha3"
"istio.io/istio/pilot/pkg/features"
"istio.io/istio/pilot/pkg/model"
@@ -1880,7 +1881,7 @@ func extractGatewayServices(r GatewayResources, kgw *k8s.GatewaySpec, obj config
if len(name) > 0 {
return []string{fmt.Sprintf("%s.%s.svc.%v", name, obj.Namespace, r.Domain)}, false, nil
}
return []string{}, true, nil
return []string{fmt.Sprintf("%s.%s.svc.%s", higressconfig.GatewayName, higressconfig.PodNamespace, util.GetDomainSuffix())}, true, nil
}
gatewayServices := []string{}
skippedAddresses := []string{}

View File

@@ -919,7 +919,7 @@ func TestExtractGatewayServices(t *testing.T) {
Namespace: "default",
},
},
gatewayServices: []string{},
gatewayServices: []string{"higress-gateway.higress-system.svc.cluster.local"},
useDefaultService: true,
},
{
@@ -1039,7 +1039,7 @@ func TestExtractGatewayServices(t *testing.T) {
Namespace: "default",
},
},
gatewayServices: []string{},
gatewayServices: []string{"higress-gateway.higress-system.svc.cluster.local"},
useDefaultService: true,
},
}

View File

@@ -187,10 +187,9 @@ func (m *IngressTranslation) List(typ config.GroupVersionKind, namespace string)
higressConfig = append(higressConfig, ingressConfig...)
if m.kingressConfig != nil {
kingressConfig := m.kingressConfig.List(typ, namespace)
if kingressConfig == nil {
return nil
if kingressConfig != nil {
higressConfig = append(higressConfig, kingressConfig...)
}
higressConfig = append(higressConfig, kingressConfig...)
}
return higressConfig
}

View File

@@ -1,5 +1,6 @@
## Wasm 插件
目前 Higress 提供了 c++ 和 golang 两种 Wasm 插件开发框架,支持 Wasm 插件路由&域名级匹配生效。
同时提供了多个内置插件,用户可以基于 Higress 提供的官方镜像仓库直接使用这些插件(以 c++ 版本举例):

View File

@@ -16,15 +16,15 @@ load("@io_bazel_rules_docker//repositories:deps.bzl", container_deps = "deps")
container_deps()
PROXY_WASM_CPP_SDK_SHA = "fd0be8405db25de0264bdb78fae3a82668c03782"
PROXY_WASM_CPP_SDK_SHA = "eaec483b5b3c7bcb89fd208b5a1fa5d79d626f61"
PROXY_WASM_CPP_SDK_SHA256 = "c57de2425b5c61d7f630c5061e319b4557ae1f1c7526e5a51c33dc1299471b08"
PROXY_WASM_CPP_SDK_SHA256 = "1140bc8114d75db56a6ca6b18423d4df50d988d40b4cec929a1eb246cf5a4a3d"
http_archive(
name = "proxy_wasm_cpp_sdk",
sha256 = PROXY_WASM_CPP_SDK_SHA256,
strip_prefix = "proxy-wasm-cpp-sdk-" + PROXY_WASM_CPP_SDK_SHA,
url = "https://github.com/proxy-wasm/proxy-wasm-cpp-sdk/archive/" + PROXY_WASM_CPP_SDK_SHA + ".tar.gz",
url = "https://github.com/higress-group/proxy-wasm-cpp-sdk/archive/" + PROXY_WASM_CPP_SDK_SHA + ".tar.gz",
)
load("@proxy_wasm_cpp_sdk//bazel/dep:deps.bzl", "wasm_dependencies")

View File

@@ -33,14 +33,14 @@ def wasm_libraries():
urls = ["https://github.com/google/googletest/archive/release-1.10.0.tar.gz"],
)
PROXY_WASM_CPP_HOST_SHA = "f38347360feaaf5b2a733f219c4d8c9660d626f0"
PROXY_WASM_CPP_HOST_SHA256 = "bf10de946eb5785813895c2bf16504afc0cd590b9655d9ee52fb1074d0825ea3"
PROXY_WASM_CPP_HOST_SHA = "7850d1721fe3dd2ccfb86a06116f76c23b1f1bf8"
PROXY_WASM_CPP_HOST_SHA256 = "740690fc1d749849f6e24b5bc48a07dabc0565a7d03b6cd13425dba693956c57"
http_archive(
name = "proxy_wasm_cpp_host",
sha256 = PROXY_WASM_CPP_HOST_SHA256,
strip_prefix = "proxy-wasm-cpp-host-" + PROXY_WASM_CPP_HOST_SHA,
url = "https://github.com/proxy-wasm/proxy-wasm-cpp-host/archive/" + PROXY_WASM_CPP_HOST_SHA +".tar.gz",
url = "https://github.com/higress-group/proxy-wasm-cpp-host/archive/" + PROXY_WASM_CPP_HOST_SHA +".tar.gz",
)
http_archive(

View File

@@ -42,10 +42,7 @@ static RegisterContextFactory register_KeyAuth(CONTEXT_FACTORY(PluginContext),
namespace {
void deniedNoKeyAuthData(const std::string& realm) {
sendLocalResponse(401, "No API key found in request", "",
{{"WWW-Authenticate", absl::StrCat("Key realm=", realm)}});
}
const std::string OriginalAuthKey("X-HI-ORIGINAL-AUTH");
void deniedInvalidCredentials(const std::string& realm) {
sendLocalResponse(401, "Request denied by Key Auth check. Invalid API key",
@@ -84,6 +81,7 @@ bool PluginRootContext::parsePluginConfig(const json& configuration,
}
if (!JsonArrayIterate(
configuration, "consumers", [&](const json& consumer) -> bool {
Consumer c;
auto item = consumer.find("name");
if (item == consumer.end()) {
LOG_WARN("can't find 'name' field in consumer.");
@@ -94,6 +92,7 @@ bool PluginRootContext::parsePluginConfig(const json& configuration,
!name.first) {
return false;
}
c.name = name.first.value();
item = consumer.find("credential");
if (item == consumer.end()) {
LOG_WARN("can't find 'credential' field in consumer.");
@@ -104,6 +103,7 @@ bool PluginRootContext::parsePluginConfig(const json& configuration,
!credential.first) {
return false;
}
c.credential = credential.first.value();
if (rule.credential_to_name.find(credential.first.value()) !=
rule.credential_to_name.end()) {
LOG_WARN(absl::StrCat("duplicate consumer credential: ",
@@ -113,15 +113,59 @@ bool PluginRootContext::parsePluginConfig(const json& configuration,
rule.credentials.insert(credential.first.value());
rule.credential_to_name.emplace(
std::make_pair(credential.first.value(), name.first.value()));
item = consumer.find("keys");
if (item != consumer.end()) {
c.keys = std::vector<std::string>{OriginalAuthKey};
if (!JsonArrayIterate(
consumer, "keys", [&](const json& key_json) -> bool {
auto key = JsonValueAs<std::string>(key_json);
if (key.second !=
Wasm::Common::JsonParserResultDetail::OK) {
return false;
}
c.keys->push_back(key.first.value());
return true;
})) {
LOG_WARN("failed to parse configuration for consumer keys.");
return false;
}
item = consumer.find("in_query");
if (item != consumer.end()) {
auto in_query = JsonValueAs<bool>(item.value());
if (in_query.second !=
Wasm::Common::JsonParserResultDetail::OK ||
!in_query.first) {
LOG_WARN(
"failed to parse 'in_query' field in consumer "
"configuration.");
return false;
}
c.in_query = in_query.first;
}
item = consumer.find("in_header");
if (item != consumer.end()) {
auto in_header = JsonValueAs<bool>(item.value());
if (in_header.second !=
Wasm::Common::JsonParserResultDetail::OK ||
!in_header.first) {
LOG_WARN(
"failed to parse 'in_header' field in consumer "
"configuration.");
return false;
}
c.in_header = in_header.first;
}
}
rule.consumers.push_back(std::move(c));
return true;
})) {
LOG_WARN("failed to parse configuration for credentials.");
return false;
}
if (rule.credentials.empty()) {
LOG_INFO("at least one credential has to be configured for a rule.");
return false;
}
// if (rule.credentials.empty()) {
// LOG_INFO("at least one credential has to be configured for a rule.");
// return false;
// }
if (!JsonArrayIterate(configuration, "keys", [&](const json& item) -> bool {
auto key = JsonValueAs<std::string>(item);
if (key.second != Wasm::Common::JsonParserResultDetail::OK) {
@@ -137,6 +181,7 @@ bool PluginRootContext::parsePluginConfig(const json& configuration,
LOG_WARN("at least one key has to be configured for a rule.");
return false;
}
rule.keys.push_back(OriginalAuthKey);
auto it = configuration.find("realm");
if (it != configuration.end()) {
auto realm_string = JsonValueAs<std::string>(it.value());
@@ -175,36 +220,102 @@ bool PluginRootContext::parsePluginConfig(const json& configuration,
bool PluginRootContext::checkPlugin(
const KeyAuthConfigRule& rule,
const std::optional<std::unordered_set<std::string>>& allow_set) {
auto credential = extractCredential(rule);
if (credential.empty()) {
LOG_DEBUG("empty credential");
deniedNoKeyAuthData(rule.realm);
return false;
}
auto auth_credential_iter = rule.credentials.find(std::string(credential));
// Check if the credential is part of the credentials
// set from our container to grant or deny access.
if (auth_credential_iter == rule.credentials.end()) {
LOG_DEBUG(absl::StrCat("api key not found: ", credential));
deniedInvalidCredentials(rule.realm);
return false;
}
// Check if this credential has a consumer name. If so, check if this
// consumer is allowed to access. If allow_set is empty, allow all consumers.
auto credential_to_name_iter =
rule.credential_to_name.find(std::string(std::string(credential)));
if (credential_to_name_iter != rule.credential_to_name.end()) {
if (allow_set && !allow_set.value().empty()) {
if (allow_set.value().find(credential_to_name_iter->second) ==
allow_set.value().end()) {
deniedUnauthorizedConsumer(rule.realm);
LOG_DEBUG(credential_to_name_iter->second);
return false;
if (rule.consumers.empty()) {
for (const auto& key : rule.keys) {
auto credential = extractCredential(rule.in_header, rule.in_query, key);
if (credential.empty()) {
LOG_DEBUG("empty credential for key: " + key);
continue;
}
auto auth_credential_iter = rule.credentials.find(credential);
if (auth_credential_iter == rule.credentials.end()) {
LOG_DEBUG("api key not found: " + credential);
continue;
}
auto credential_to_name_iter = rule.credential_to_name.find(credential);
if (credential_to_name_iter != rule.credential_to_name.end()) {
if (allow_set && !allow_set->empty()) {
if (allow_set->find(credential_to_name_iter->second) ==
allow_set->end()) {
deniedUnauthorizedConsumer(rule.realm);
LOG_DEBUG("unauthorized consumer: " +
credential_to_name_iter->second);
return false;
}
}
addRequestHeader("X-Mse-Consumer", credential_to_name_iter->second);
}
return true;
}
} else {
for (const auto& consumer : rule.consumers) {
std::vector<std::string> keys_to_check =
consumer.keys.value_or(rule.keys);
bool in_query = consumer.in_query.value_or(rule.in_query);
bool in_header = consumer.in_header.value_or(rule.in_header);
for (const auto& key : keys_to_check) {
auto credential = extractCredential(in_header, in_query, key);
if (credential.empty()) {
LOG_DEBUG("empty credential for key: " + key);
continue;
}
if (credential != consumer.credential) {
LOG_DEBUG("credential does not match the consumer's credential: " +
credential);
continue;
}
auto auth_credential_iter = rule.credentials.find(credential);
if (auth_credential_iter == rule.credentials.end()) {
LOG_DEBUG("api key not found: " + credential);
continue;
}
auto credential_to_name_iter = rule.credential_to_name.find(credential);
if (credential_to_name_iter != rule.credential_to_name.end()) {
if (allow_set && !allow_set->empty()) {
if (allow_set->find(credential_to_name_iter->second) ==
allow_set->end()) {
deniedUnauthorizedConsumer(rule.realm);
LOG_DEBUG("unauthorized consumer: " +
credential_to_name_iter->second);
return false;
}
}
addRequestHeader("X-Mse-Consumer", credential_to_name_iter->second);
}
return true;
}
}
addRequestHeader("X-Mse-Consumer", credential_to_name_iter->second);
}
return true;
LOG_DEBUG("No valid credentials were found after checking all consumers.");
deniedInvalidCredentials(rule.realm);
return false;
}
std::string PluginRootContext::extractCredential(bool in_header, bool in_query,
const std::string& key) {
if (in_header) {
auto header = getRequestHeader(key);
if (header->size() != 0) {
return header->toString();
}
}
if (in_query) {
auto request_path_header = getRequestHeader(":path");
auto path = request_path_header->view();
auto params = Wasm::Common::Http::parseAndDecodeQueryString(path);
auto it = params.find(key);
if (it != params.end()) {
return it->second;
}
}
return "";
}
bool PluginRootContext::onConfigure(size_t size) {
@@ -234,31 +345,6 @@ bool PluginRootContext::configure(size_t configuration_size) {
return true;
}
std::string PluginRootContext::extractCredential(
const KeyAuthConfigRule& rule) {
auto request_path_header = getRequestHeader(":path");
auto path = request_path_header->view();
LOG_DEBUG(std::string(path));
if (rule.in_query) {
auto params = Wasm::Common::Http::parseAndDecodeQueryString(path);
for (const auto& key : rule.keys) {
auto it = params.find(key);
if (it != params.end()) {
return it->second;
}
}
}
if (rule.in_header) {
for (const auto& key : rule.keys) {
auto header = getRequestHeader(key);
if (header->size() != 0) {
return header->toString();
}
}
}
return "";
}
FilterHeadersStatus PluginContext::onRequestHeaders(uint32_t, bool) {
auto* rootCtx = rootContext();
return rootCtx->checkAuthRule(

View File

@@ -36,7 +36,16 @@ namespace key_auth {
#endif
struct Consumer {
std::string name;
std::string credential;
std::optional<std::vector<std::string>> keys;
std::optional<bool> in_query = std::nullopt;
std::optional<bool> in_header = std::nullopt;
};
struct KeyAuthConfigRule {
std::vector<Consumer> consumers;
std::unordered_set<std::string> credentials;
std::unordered_map<std::string, std::string> credential_to_name;
std::string realm = "MSE Gateway";
@@ -61,7 +70,8 @@ class PluginRootContext : public RootContext,
private:
bool parsePluginConfig(const json&, KeyAuthConfigRule&) override;
std::string extractCredential(const KeyAuthConfigRule&);
std::string extractCredential(bool in_header, bool in_query,
const std::string& key);
};
// Per-stream context.

View File

@@ -121,7 +121,7 @@ TEST_F(KeyAuthTest, InQuery) {
"_rules_": [
{
"_match_route_": ["test"],
"credentials":["abc"],
"credentials":["abc","def"],
"keys": ["apiKey", "x-api-key"]
}
]
@@ -144,6 +144,10 @@ TEST_F(KeyAuthTest, InQuery) {
path_ = "/test?hello=123&apiKey=123";
EXPECT_EQ(context_->onRequestHeaders(0, false),
FilterHeadersStatus::StopIteration);
path_ = "/test?hello=123&apiKey=123&x-api-key=def";
EXPECT_EQ(context_->onRequestHeaders(0, false),
FilterHeadersStatus::Continue);
}
TEST_F(KeyAuthTest, InQueryWithConsumer) {
@@ -173,6 +177,29 @@ TEST_F(KeyAuthTest, InQueryWithConsumer) {
FilterHeadersStatus::StopIteration);
}
TEST_F(KeyAuthTest, EmptyConsumer) {
std::string configuration = R"(
{
"consumers" : [],
"keys" : [ "apiKey", "x-api-key" ],
"_rules_" : [ {"_match_route_" : ["test"], "allow" : []} ]
})";
BufferBase buffer;
buffer.set(configuration);
EXPECT_CALL(*mock_context_, getBuffer(WasmBufferType::PluginConfiguration))
.WillOnce([&buffer](WasmBufferType) { return &buffer; });
EXPECT_TRUE(root_context_->configure(configuration.size()));
route_name_ = "test";
path_ = "/test?hello=1&apiKey=abc";
EXPECT_EQ(context_->onRequestHeaders(0, false),
FilterHeadersStatus::StopIteration);
route_name_ = "test2";
EXPECT_EQ(context_->onRequestHeaders(0, false),
FilterHeadersStatus::Continue);
}
TEST_F(KeyAuthTest, InHeader) {
std::string configuration = R"(
{
@@ -240,6 +267,40 @@ TEST_F(KeyAuthTest, InHeaderWithConsumer) {
FilterHeadersStatus::StopIteration);
}
TEST_F(KeyAuthTest, ConsumerDifferentKey) {
std::string configuration = R"(
{
"consumers" : [ {"credential" : "abc", "name" : "consumer1", "keys" : [ "apiKey" ]}, {"credential" : "123", "name" : "consumer2"} ],
"keys" : [ "apiKey2" ],
"_rules_" : [ {"_match_route_" : ["test"], "allow" : ["consumer1"]}, {"_match_route_" : ["test2"], "allow" : ["consumer2"]} ]
})";
BufferBase buffer;
buffer.set(configuration);
EXPECT_CALL(*mock_context_, getBuffer(WasmBufferType::PluginConfiguration))
.WillOnce([&buffer](WasmBufferType) { return &buffer; });
EXPECT_TRUE(root_context_->configure(configuration.size()));
route_name_ = "test";
path_ = "/test?hello=1&apiKey=abc";
EXPECT_EQ(context_->onRequestHeaders(0, false),
FilterHeadersStatus::Continue);
route_name_ = "test";
path_ = "/test?hello=1&apiKey2=abc";
EXPECT_EQ(context_->onRequestHeaders(0, false),
FilterHeadersStatus::StopIteration);
route_name_ = "test";
path_ = "/test?hello=123&apiKey2=123";
EXPECT_EQ(context_->onRequestHeaders(0, false),
FilterHeadersStatus::StopIteration);
route_name_ = "test2";
path_ = "/test?hello=123&apiKey2=123";
EXPECT_EQ(context_->onRequestHeaders(0, false),
FilterHeadersStatus::Continue);
}
} // namespace key_auth
} // namespace null_plugin
} // namespace proxy_wasm

View File

@@ -0,0 +1,70 @@
# 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.
load("@proxy_wasm_cpp_sdk//bazel/wasm:wasm.bzl", "wasm_cc_binary")
load("//bazel:wasm.bzl", "declare_wasm_image_targets")
wasm_cc_binary(
name = "model_router.wasm",
srcs = [
"plugin.cc",
"plugin.h",
],
deps = [
"@proxy_wasm_cpp_sdk//:proxy_wasm_intrinsics_higress",
"@com_google_absl//absl/strings",
"@com_google_absl//absl/time",
"//common:json_util",
"//common:http_util",
"//common:rule_util",
],
)
cc_library(
name = "model_router_lib",
srcs = [
"plugin.cc",
],
hdrs = [
"plugin.h",
],
copts = ["-DNULL_PLUGIN"],
deps = [
"@com_google_absl//absl/strings",
"@com_google_absl//absl/time",
"//common:json_util",
"@proxy_wasm_cpp_host//:lib",
"//common:http_util_nullvm",
"//common:rule_util_nullvm",
],
)
cc_test(
name = "model_router_test",
srcs = [
"plugin_test.cc",
],
copts = ["-DNULL_PLUGIN"],
deps = [
":model_router_lib",
"@com_google_googletest//:gtest",
"@com_google_googletest//:gtest_main",
"@proxy_wasm_cpp_host//:lib",
],
)
declare_wasm_image_targets(
name = "model_router",
wasm_file = ":model_router.wasm",
)

View File

@@ -0,0 +1,64 @@
## 功能说明
`model-router`插件实现了基于LLM协议中的model参数路由的功能
## 运行属性
插件执行阶段:`默认阶段`
插件执行优先级:`260`
## 配置字段
| 名称 | 数据类型 | 填写要求 | 默认值 | 描述 |
| ----------- | --------------- | ----------------------- | ------ | ------------------------------------------- |
| `enable` | bool | 选填 | false | 是否开启基于model参数路由 |
| `model_key` | string | 选填 | model | 请求body中model参数的位置 |
| `add_header_key` | string | 选填 | x-higress-llm-provider | 从model参数中解析出的provider名字放到哪个请求header中 |
## 效果说明
如下开启基于model参数路由的功能
```yaml
enable: true
```
开启后,插件将请求中 model 参数的 provider 部分(如果有)提取出来,设置到 x-higress-llm-provider 这个请求 header 中,用于后续路由,并将 model 参数重写为模型名称部分。举例来说,原生的 LLM 请求体是:
```json
{
"model": "qwen/qwen-long",
"frequency_penalty": 0,
"max_tokens": 800,
"stream": false,
"messages": [{
"role": "user",
"content": "higress项目主仓库的github地址是什么"
}],
"presence_penalty": 0,
"temperature": 0.7,
"top_p": 0.95
}
```
经过这个插件后,将添加下面这个请求头(可以用于路由匹配)
x-higress-llm-provider: qwen
原始的 LLM 请求体将被改成:
```json
{
"model": "qwen-long",
"frequency_penalty": 0,
"max_tokens": 800,
"stream": false,
"messages": [{
"role": "user",
"content": "higress项目主仓库的github地址是什么"
}],
"presence_penalty": 0,
"temperature": 0.7,
"top_p": 0.95
}
```

View File

@@ -0,0 +1,63 @@
## Function Description
The `model-router` plugin implements the functionality of routing based on the `model` parameter in the LLM protocol.
## Runtime Properties
Plugin Execution Phase: `Default Phase`
Plugin Execution Priority: `260`
## Configuration Fields
| Name | Data Type | Filling Requirement | Default Value | Description |
| -------------------- | ------------- | --------------------- | ---------------------- | ----------------------------------------------------- |
| `enable` | bool | Optional | false | Whether to enable routing based on the `model` parameter |
| `model_key` | string | Optional | model | The location of the `model` parameter in the request body |
| `add_header_key` | string | Optional | x-higress-llm-provider | The header where the parsed provider name from the `model` parameter will be placed |
## Effect Description
To enable routing based on the `model` parameter, use the following configuration:
```yaml
enable: true
```
After enabling, the plugin extracts the provider part (if any) from the `model` parameter in the request, and sets it in the `x-higress-llm-provider` request header for subsequent routing. It also rewrites the `model` parameter to the model name part. For example, the original LLM request body is:
```json
{
"model": "openai/gpt-4o",
"frequency_penalty": 0,
"max_tokens": 800,
"stream": false,
"messages": [{
"role": "user",
"content": "What is the GitHub address for the main repository of the Higress project?"
}],
"presence_penalty": 0,
"temperature": 0.7,
"top_p": 0.95
}
```
After processing by the plugin, the following request header (which can be used for routing matching) will be added:
`x-higress-llm-provider: openai`
The original LLM request body will be modified to:
```json
{
"model": "gpt-4o",
"frequency_penalty": 0,
"max_tokens": 800,
"stream": false,
"messages": [{
"role": "user",
"content": "What is the GitHub address for the main repository of the Higress project?"
}],
"presence_penalty": 0,
"temperature": 0.7,
"top_p": 0.95
}
```

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.
#include "extensions/model_router/plugin.h"
#include <array>
#include <limits>
#include "absl/strings/str_cat.h"
#include "absl/strings/str_split.h"
#include "common/http_util.h"
#include "common/json_util.h"
using ::nlohmann::json;
using ::Wasm::Common::JsonArrayIterate;
using ::Wasm::Common::JsonGetField;
using ::Wasm::Common::JsonObjectIterate;
using ::Wasm::Common::JsonValueAs;
#ifdef NULL_PLUGIN
namespace proxy_wasm {
namespace null_plugin {
namespace model_router {
PROXY_WASM_NULL_PLUGIN_REGISTRY
#endif
static RegisterContextFactory register_ModelRouter(
CONTEXT_FACTORY(PluginContext), ROOT_FACTORY(PluginRootContext));
namespace {
constexpr std::string_view SetDecoderBufferLimitKey =
"SetRequestBodyBufferLimit";
constexpr std::string_view DefaultMaxBodyBytes = "10485760";
} // namespace
bool PluginRootContext::parsePluginConfig(const json& configuration,
ModelRouterConfigRule& rule) {
if (auto it = configuration.find("enable"); it != configuration.end()) {
if (it->is_boolean()) {
rule.enable_ = it->get<bool>();
} else {
LOG_WARN("Invalid type for enable. Expected boolean.");
return false;
}
}
if (auto it = configuration.find("model_key"); it != configuration.end()) {
if (it->is_string()) {
rule.model_key_ = it->get<std::string>();
} else {
LOG_WARN("Invalid type for model_key. Expected string.");
return false;
}
}
if (auto it = configuration.find("add_header_key");
it != configuration.end()) {
if (it->is_string()) {
rule.add_header_key_ = it->get<std::string>();
} else {
LOG_WARN("Invalid type for add_header_key. Expected string.");
return false;
}
}
return true;
}
bool PluginRootContext::onConfigure(size_t size) {
// Parse configuration JSON string.
if (size > 0 && !configure(size)) {
LOG_WARN("configuration has errors initialization will not continue.");
return false;
}
return true;
}
bool PluginRootContext::configure(size_t configuration_size) {
auto configuration_data = getBufferBytes(WasmBufferType::PluginConfiguration,
0, configuration_size);
// Parse configuration JSON string.
auto result = ::Wasm::Common::JsonParse(configuration_data->view());
if (!result) {
LOG_WARN(absl::StrCat("cannot parse plugin configuration JSON string: ",
configuration_data->view()));
return false;
}
if (!parseRuleConfig(result.value())) {
LOG_WARN(absl::StrCat("cannot parse plugin configuration JSON string: ",
configuration_data->view()));
return false;
}
return true;
}
FilterHeadersStatus PluginRootContext::onHeader(
const ModelRouterConfigRule& rule) {
if (!rule.enable_ || !Wasm::Common::Http::hasRequestBody()) {
return FilterHeadersStatus::Continue;
}
auto content_type_value =
getRequestHeader(Wasm::Common::Http::Header::ContentType);
if (!absl::StrContains(content_type_value->view(),
Wasm::Common::Http::ContentTypeValues::Json)) {
return FilterHeadersStatus::Continue;
}
removeRequestHeader(Wasm::Common::Http::Header::ContentLength);
setFilterState(SetDecoderBufferLimitKey, DefaultMaxBodyBytes);
return FilterHeadersStatus::StopIteration;
}
FilterDataStatus PluginRootContext::onBody(const ModelRouterConfigRule& rule,
std::string_view body) {
const auto& model_key = rule.model_key_;
const auto& add_header_key = rule.add_header_key_;
auto body_json_opt = ::Wasm::Common::JsonParse(body);
if (!body_json_opt) {
LOG_WARN(absl::StrCat("cannot parse body to JSON string: ", body));
return FilterDataStatus::Continue;
}
auto body_json = body_json_opt.value();
if (body_json.contains(model_key)) {
std::string model_value = body_json[model_key];
auto pos = model_value.find('/');
if (pos != std::string::npos) {
const auto& provider = model_value.substr(0, pos);
const auto& model = model_value.substr(pos + 1);
replaceRequestHeader(add_header_key, provider);
body_json[model_key] = model;
setBuffer(WasmBufferType::HttpRequestBody, 0,
std::numeric_limits<size_t>::max(), body_json.dump());
LOG_DEBUG(absl::StrCat("model route to provider:", provider,
", model:", model));
} else {
LOG_DEBUG(absl::StrCat("model route not work, model:", model_value));
}
}
return FilterDataStatus::Continue;
}
FilterHeadersStatus PluginContext::onRequestHeaders(uint32_t, bool) {
auto* rootCtx = rootContext();
return rootCtx->onHeaders([rootCtx, this](const auto& config) {
auto ret = rootCtx->onHeader(config);
if (ret == FilterHeadersStatus::StopIteration) {
this->config_ = &config;
}
return ret;
});
}
FilterDataStatus PluginContext::onRequestBody(size_t body_size,
bool end_stream) {
if (config_ == nullptr) {
return FilterDataStatus::Continue;
}
body_total_size_ += body_size;
if (!end_stream) {
return FilterDataStatus::StopIterationAndBuffer;
}
auto body =
getBufferBytes(WasmBufferType::HttpRequestBody, 0, body_total_size_);
auto* rootCtx = rootContext();
return rootCtx->onBody(*config_, body->view());
}
#ifdef NULL_PLUGIN
} // namespace model_router
} // namespace null_plugin
} // namespace proxy_wasm
#endif

View File

@@ -0,0 +1,85 @@
/*
* 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.
*/
#include <assert.h>
#include <string>
#include <unordered_set>
#include "common/route_rule_matcher.h"
#define ASSERT(_X) assert(_X)
#ifndef NULL_PLUGIN
#include "proxy_wasm_intrinsics.h"
#else
#include "include/proxy-wasm/null_plugin.h"
namespace proxy_wasm {
namespace null_plugin {
namespace model_router {
#endif
struct ModelRouterConfigRule {
bool enable_ = false;
std::string model_key_ = "model";
std::string add_header_key_ = "x-higress-llm-provider";
};
// PluginRootContext is the root context for all streams processed by the
// thread. It has the same lifetime as the worker thread and acts as target for
// interactions that outlives individual stream, e.g. timer, async calls.
class PluginRootContext : public RootContext,
public RouteRuleMatcher<ModelRouterConfigRule> {
public:
PluginRootContext(uint32_t id, std::string_view root_id)
: RootContext(id, root_id) {}
~PluginRootContext() {}
bool onConfigure(size_t) override;
FilterHeadersStatus onHeader(const ModelRouterConfigRule&);
FilterDataStatus onBody(const ModelRouterConfigRule&, std::string_view);
bool configure(size_t);
private:
bool parsePluginConfig(const json&, ModelRouterConfigRule&) override;
};
// Per-stream context.
class PluginContext : public Context {
public:
explicit PluginContext(uint32_t id, RootContext* root) : Context(id, root) {}
FilterHeadersStatus onRequestHeaders(uint32_t, bool) override;
FilterDataStatus onRequestBody(size_t, bool) override;
private:
inline PluginRootContext* rootContext() {
return dynamic_cast<PluginRootContext*>(this->root());
}
size_t body_total_size_ = 0;
const ModelRouterConfigRule* config_ = nullptr;
};
#ifdef NULL_PLUGIN
} // namespace model_router
} // namespace null_plugin
} // namespace proxy_wasm
#endif

View File

@@ -0,0 +1,192 @@
// 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.
#include "extensions/model_router/plugin.h"
#include <cstddef>
#include "gmock/gmock.h"
#include "gtest/gtest.h"
#include "include/proxy-wasm/context.h"
#include "include/proxy-wasm/null.h"
namespace proxy_wasm {
namespace null_plugin {
namespace model_router {
NullPluginRegistry* context_registry_;
RegisterNullVmPluginFactory register_model_router_plugin("model_router", []() {
return std::make_unique<NullPlugin>(model_router::context_registry_);
});
class MockContext : public proxy_wasm::ContextBase {
public:
MockContext(WasmBase* wasm) : ContextBase(wasm) {}
MOCK_METHOD(BufferInterface*, getBuffer, (WasmBufferType));
MOCK_METHOD(WasmResult, log, (uint32_t, std::string_view));
MOCK_METHOD(WasmResult, setBuffer,
(WasmBufferType, size_t, size_t, std::string_view));
MOCK_METHOD(WasmResult, getHeaderMapValue,
(WasmHeaderMapType /* type */, std::string_view /* key */,
std::string_view* /*result */));
MOCK_METHOD(WasmResult, replaceHeaderMapValue,
(WasmHeaderMapType /* type */, std::string_view /* key */,
std::string_view /* value */));
MOCK_METHOD(WasmResult, removeHeaderMapValue,
(WasmHeaderMapType /* type */, std::string_view /* key */));
MOCK_METHOD(WasmResult, addHeaderMapValue,
(WasmHeaderMapType, std::string_view, std::string_view));
MOCK_METHOD(WasmResult, getProperty, (std::string_view, std::string*));
MOCK_METHOD(WasmResult, setProperty, (std::string_view, std::string_view));
};
class ModelRouterTest : public ::testing::Test {
protected:
ModelRouterTest() {
// Initialize test VM
test_vm_ = createNullVm();
wasm_base_ = std::make_unique<WasmBase>(
std::move(test_vm_), "test-vm", "", "",
std::unordered_map<std::string, std::string>{},
AllowedCapabilitiesMap{});
wasm_base_->load("model_router");
wasm_base_->initialize();
// Initialize host side context
mock_context_ = std::make_unique<MockContext>(wasm_base_.get());
current_context_ = mock_context_.get();
// Initialize Wasm sandbox context
root_context_ = std::make_unique<PluginRootContext>(0, "");
context_ = std::make_unique<PluginContext>(1, root_context_.get());
ON_CALL(*mock_context_, log(testing::_, testing::_))
.WillByDefault([](uint32_t, std::string_view m) {
std::cerr << m << "\n";
return WasmResult::Ok;
});
ON_CALL(*mock_context_, getBuffer(testing::_))
.WillByDefault([&](WasmBufferType type) {
if (type == WasmBufferType::HttpRequestBody) {
return &body_;
}
return &config_;
});
ON_CALL(*mock_context_, getHeaderMapValue(WasmHeaderMapType::RequestHeaders,
testing::_, testing::_))
.WillByDefault([&](WasmHeaderMapType, std::string_view header,
std::string_view* result) {
if (header == "content-type") {
*result = "application/json";
} else if (header == "content-length") {
*result = "1024";
}
return WasmResult::Ok;
});
ON_CALL(*mock_context_,
replaceHeaderMapValue(WasmHeaderMapType::RequestHeaders, testing::_,
testing::_))
.WillByDefault([&](WasmHeaderMapType, std::string_view key,
std::string_view value) { return WasmResult::Ok; });
ON_CALL(*mock_context_,
removeHeaderMapValue(WasmHeaderMapType::RequestHeaders, testing::_))
.WillByDefault([&](WasmHeaderMapType, std::string_view key) {
return WasmResult::Ok;
});
ON_CALL(*mock_context_, addHeaderMapValue(WasmHeaderMapType::RequestHeaders,
testing::_, testing::_))
.WillByDefault([&](WasmHeaderMapType, std::string_view header,
std::string_view value) { return WasmResult::Ok; });
ON_CALL(*mock_context_, getProperty(testing::_, testing::_))
.WillByDefault([&](std::string_view path, std::string* result) {
*result = route_name_;
return WasmResult::Ok;
});
ON_CALL(*mock_context_, setProperty(testing::_, testing::_))
.WillByDefault(
[&](std::string_view, std::string_view) { return WasmResult::Ok; });
}
~ModelRouterTest() override {}
std::unique_ptr<WasmBase> wasm_base_;
std::unique_ptr<WasmVm> test_vm_;
std::unique_ptr<MockContext> mock_context_;
std::unique_ptr<PluginRootContext> root_context_;
std::unique_ptr<PluginContext> context_;
std::string route_name_;
BufferBase body_;
BufferBase config_;
};
TEST_F(ModelRouterTest, RewriteModelAndHeader) {
std::string configuration = R"(
{
"enable": true
})";
config_.set(configuration);
EXPECT_TRUE(root_context_->configure(configuration.size()));
std::string request_json = R"({"model": "qwen/qwen-long"})";
EXPECT_CALL(*mock_context_,
setBuffer(testing::_, testing::_, testing::_, testing::_))
.WillOnce([&](WasmBufferType, size_t, size_t, std::string_view body) {
EXPECT_EQ(body, R"({"model":"qwen-long"})");
return WasmResult::Ok;
});
EXPECT_CALL(*mock_context_,
replaceHeaderMapValue(testing::_,
std::string_view("x-higress-llm-provider"),
std::string_view("qwen")));
body_.set(request_json);
EXPECT_EQ(context_->onRequestHeaders(0, false),
FilterHeadersStatus::StopIteration);
EXPECT_EQ(context_->onRequestBody(28, true), FilterDataStatus::Continue);
}
TEST_F(ModelRouterTest, RouteLevelRewriteModelAndHeader) {
std::string configuration = R"(
{
"_rules_": [
{
"_match_route_": ["route-a"],
"enable": true
}
]})";
config_.set(configuration);
EXPECT_TRUE(root_context_->configure(configuration.size()));
std::string request_json = R"({"model": "qwen/qwen-long"})";
EXPECT_CALL(*mock_context_,
setBuffer(testing::_, testing::_, testing::_, testing::_))
.WillOnce([&](WasmBufferType, size_t, size_t, std::string_view body) {
EXPECT_EQ(body, R"({"model":"qwen-long"})");
return WasmResult::Ok;
});
EXPECT_CALL(*mock_context_,
replaceHeaderMapValue(testing::_,
std::string_view("x-higress-llm-provider"),
std::string_view("qwen")));
body_.set(request_json);
route_name_ = "route-a";
EXPECT_EQ(context_->onRequestHeaders(0, false),
FilterHeadersStatus::StopIteration);
EXPECT_EQ(context_->onRequestBody(28, true), FilterDataStatus::Continue);
}
} // namespace model_router
} // namespace null_plugin
} // namespace proxy_wasm

View File

@@ -55,6 +55,9 @@ output wasm file: extensions/request-block/plugin.wasm
tinygo build -o main.wasm -scheduler=none -target=wasi -gc=custom -tags='custommalloc nottinygc_finalizer' ./extensions/request-block/main.go
```
详细的编译说明,包括要使用更复杂的 Header 状态管理机制,请参考[ Go 开发插件的最佳实践](https://higress.io/docs/latest/user/wasm-go/#3-%E7%BC%96%E8%AF%91%E7%94%9F%E6%88%90-wasm-%E6%96%87%E4%BB%B6)。
### step2. 构建并推送插件的 docker 镜像
使用这份简单的 Dockerfile
@@ -201,4 +204,4 @@ cSuite.Setup(t)
```bash
PLUGIN_NAME=request-block make higress-wasmplugin-test
```
```

View File

@@ -1,5 +1,5 @@
# File generated by hgctl. Modify as required.
docker-compose-test/
*
!/.gitignore

View File

@@ -1,46 +1,137 @@
## 简介
---
title: AI 缓存
keywords: [higress,ai cache]
description: AI 缓存插件配置参考
---
**Note**
> 需要数据面的proxy wasm版本大于等于0.2.100
> 编译时需要带上版本的tag例如`tinygo build -o main.wasm -scheduler=none -target=wasi -gc=custom -tags="custommalloc nottinygc_finalizer proxy_wasm_version_0_2_100" ./`
>
## 功能说明
LLM 结果缓存插件,默认配置方式可以直接用于 openai 协议的结果缓存,同时支持流式和非流式响应的缓存。
**提示**
携带请求头`x-higress-skip-ai-cache: on`时,当前请求将不会使用缓存中的内容,而是直接转发给后端服务,同时也不会缓存该请求返回响应的内容
## 运行属性
插件执行阶段:`认证阶段`
插件执行优先级:`10`
## 配置说明
配置分为 3 个部分向量数据库vector文本向量化接口embedding缓存数据库cache同时也提供了细粒度的 LLM 请求/响应提取参数配置等。
## 配置说明
本插件同时支持基于向量数据库的语义化缓存和基于字符串匹配的缓存方法,如果同时配置了向量数据库和缓存数据库,优先使用向量数据库。
*Note*: 向量数据库(vector) 和 缓存数据库(cache) 不能同时为空,否则本插件无法提供缓存服务。
| Name | Type | Requirement | Default | Description |
| --- | --- | --- | --- | --- |
| vector | string | optional | "" | 向量存储服务提供者类型,例如 dashvector |
| embedding | string | optional | "" | 请求文本向量化服务类型,例如 dashscope |
| cache | string | optional | "" | 缓存服务类型,例如 redis |
| cacheKeyStrategy | string | optional | "lastQuestion" | 决定如何根据历史问题生成缓存键的策略。可选值: "lastQuestion" (使用最后一个问题), "allQuestions" (拼接所有问题) 或 "disabled" (禁用缓存) |
| enableSemanticCache | bool | optional | true | 是否启用语义化缓存, 若不启用则使用字符串匹配的方式来查找缓存此时需要配置cache服务 |
根据是否需要启用语义缓存,可以只配置组件的组合为:
1. `cache`: 仅启用字符串匹配缓存
3. `vector (+ embedding)`: 启用语义化缓存, 其中若 `vector` 未提供字符串表征服务,则需要自行配置 `embedding` 服务
2. `vector (+ embedding) + cache`: 启用语义化缓存并用缓存服务存储LLM响应以加速
注意若不配置相关组件,则可以忽略相应组件的`required`字段。
## 向量数据库服务vector
| Name | Type | Requirement | Default | Description |
| --- | --- | --- | --- | --- |
| vector.type | string | required | "" | 向量存储服务提供者类型,例如 dashvector |
| vector.serviceName | string | required | "" | 向量存储服务名称 |
| vector.serviceHost | string | required | "" | 向量存储服务域名 |
| vector.servicePort | int64 | optional | 443 | 向量存储服务端口 |
| vector.apiKey | string | optional | "" | 向量存储服务 API Key |
| vector.topK | int | optional | 1 | 返回TopK结果默认为 1 |
| vector.timeout | uint32 | optional | 10000 | 请求向量存储服务的超时时间单位为毫秒。默认值是10000即10秒 |
| vector.collectionID | string | optional | "" | dashvector 向量存储服务 Collection ID |
| vector.threshold | float64 | optional | 1000 | 向量相似度度量阈值 |
| vector.thresholdRelation | string | optional | lt | 相似度度量方式有 `Cosine`, `DotProduct`, `Euclidean` 等,前两者值越大相似度越高,后者值越小相似度越高。对于 `Cosine``DotProduct` 选择 `gt`,对于 `Euclidean` 则选择 `lt`。默认为 `lt`,所有条件包括 `lt` (less than小于)、`lte` (less than or equal to小等于)、`gt` (greater than大于)、`gte` (greater than or equal to大等于) |
## 文本向量化服务embedding
| Name | Type | Requirement | Default | Description |
| --- | --- | --- | --- | --- |
| embedding.type | string | required | "" | 请求文本向量化服务类型,例如 dashscope |
| embedding.serviceName | string | required | "" | 请求文本向量化服务名称 |
| embedding.serviceHost | string | optional | "" | 请求文本向量化服务域名 |
| embedding.servicePort | int64 | optional | 443 | 请求文本向量化服务端口 |
| embedding.apiKey | string | optional | "" | 请求文本向量化服务的 API Key |
| embedding.timeout | uint32 | optional | 10000 | 请求文本向量化服务的超时时间单位为毫秒。默认值是10000即10秒 |
| embedding.model | string | optional | "" | 请求文本向量化服务的模型名称 |
## 缓存服务cache
| cache.type | string | required | "" | 缓存服务类型,例如 redis |
| --- | --- | --- | --- | --- |
| cache.serviceName | string | required | "" | 缓存服务名称 |
| cache.serviceHost | string | required | "" | 缓存服务域名 |
| cache.servicePort | int64 | optional | 6379 | 缓存服务端口 |
| cache.username | string | optional | "" | 缓存服务用户名 |
| cache.password | string | optional | "" | 缓存服务密码 |
| cache.timeout | uint32 | optional | 10000 | 缓存服务的超时时间单位为毫秒。默认值是10000即10秒 |
| cache.cacheTTL | int | optional | 0 | 缓存过期时间,单位为秒。默认值是 0即 永不过期|
| cacheKeyPrefix | string | optional | "higress-ai-cache:" | 缓存 Key 的前缀,默认值为 "higress-ai-cache:" |
## 其他配置
| Name | Type | Requirement | Default | Description |
| --- | --- | --- | --- | --- |
| cacheKeyFrom | string | optional | "messages.@reverse.0.content" | 从请求 Body 中基于 [GJSON PATH](https://github.com/tidwall/gjson/blob/master/SYNTAX.md) 语法提取字符串 |
| cacheValueFrom | string | optional | "choices.0.message.content" | 从响应 Body 中基于 [GJSON PATH](https://github.com/tidwall/gjson/blob/master/SYNTAX.md) 语法提取字符串 |
| cacheStreamValueFrom | string | optional | "choices.0.delta.content" | 从流式响应 Body 中基于 [GJSON PATH](https://github.com/tidwall/gjson/blob/master/SYNTAX.md) 语法提取字符串 |
| cacheToolCallsFrom | string | optional | "choices.0.delta.content.tool_calls" | 从请求 Body 中基于 [GJSON PATH](https://github.com/tidwall/gjson/blob/master/SYNTAX.md) 语法提取字符串 |
| responseTemplate | string | optional | `{"id":"ai-cache.hit","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}}` | 返回 HTTP 响应的模版,用 %s 标记需要被 cache value 替换的部分 |
| streamResponseTemplate | string | optional | `data:{"id":"ai-cache.hit","choices":[{"index":0,"delta":{"role":"assistant","content":%s},"finish_reason":"stop"}],"model":"gpt-4o","object":"chat.completion","usage":{"prompt_tokens":0,"completion_tokens":0,"total_tokens":0}}\n\ndata:[DONE]\n\n` | 返回流式 HTTP 响应的模版,用 %s 标记需要被 cache value 替换的部分 |
| Name | Type | Requirement | Default | Description |
| -------- | -------- | -------- | -------- | -------- |
| cacheKeyFrom.requestBody | string | optional | "messages.@reverse.0.content" | 从请求 Body 中基于 [GJSON PATH](https://github.com/tidwall/gjson/blob/master/SYNTAX.md) 语法提取字符串 |
| cacheValueFrom.responseBody | string | optional | "choices.0.message.content" | 从响应 Body 中基于 [GJSON PATH](https://github.com/tidwall/gjson/blob/master/SYNTAX.md) 语法提取字符串 |
| cacheStreamValueFrom.responseBody | string | optional | "choices.0.delta.content" | 从流式响应 Body 中基于 [GJSON PATH](https://github.com/tidwall/gjson/blob/master/SYNTAX.md) 语法提取字符串 |
| cacheKeyPrefix | string | optional | "higress-ai-cache:" | Redis缓存Key的前缀 |
| cacheTTL | integer | optional | 0 | 缓存的过期时间单位是秒默认值为0即永不过期 |
| redis.serviceName | string | requried | - | redis 服务名称,带服务类型的完整 FQDN 名称,例如 my-redis.dns、redis.my-ns.svc.cluster.local |
| redis.servicePort | integer | optional | 6379 | redis 服务端口 |
| redis.timeout | integer | optional | 1000 | 请求 redis 的超时时间,单位为毫秒 |
| redis.username | string | optional | - | 登陆 redis 的用户名 |
| redis.password | string | optional | - | 登陆 redis 的密码 |
| returnResponseTemplate | string | optional | `{"id":"from-cache","choices":[%s],"model":"gpt-4o","object":"chat.completion","usage":{"prompt_tokens":0,"completion_tokens":0,"total_tokens":0}}` | 返回 HTTP 响应的模版,用 %s 标记需要被 cache value 替换的部分 |
| returnStreamResponseTemplate | string | optional | `data:{"id":"from-cache","choices":[{"index":0,"delta":{"role":"assistant","content":"%s"},"finish_reason":"stop"}],"model":"gpt-4o","object":"chat.completion","usage":{"prompt_tokens":0,"completion_tokens":0,"total_tokens":0}}\n\ndata:[DONE]\n\n` | 返回流式 HTTP 响应的模版,用 %s 标记需要被 cache value 替换的部分 |
## 配置示例
### 基础配置
```yaml
embedding:
type: dashscope
serviceName: my_dashscope.dns
apiKey: [Your Key]
vector:
type: dashvector
serviceName: my_dashvector.dns
collectionID: [Your Collection ID]
serviceDomain: [Your domain]
apiKey: [Your key]
cache:
type: redis
serviceName: my_redis.dns
servicePort: 6379
timeout: 100
```
旧版本配置兼容
```yaml
redis:
serviceName: my-redis.dns
timeout: 2000
serviceName: my_redis.dns
servicePort: 6379
timeout: 100
```
## 进阶用法
当前默认的缓存 key 是基于 GJSON PATH 的表达式:`messages.@reverse.0.content` 提取,含义是把 messages 数组反转后取第一项的 content
GJSON PATH 支持条件判断语法,例如希望取最后一个 role 为 user 的 content 作为 key可以写成 `messages.@reverse.#(role=="user").content`
@@ -50,3 +141,7 @@ GJSON PATH 支持条件判断语法,例如希望取最后一个 role 为 user
还可以支持管道语法,例如希望取到数第二个 role 为 user 的 content 作为 key可以写成`messages.@reverse.#(role=="user")#.content|1`
更多用法可以参考[官方文档](https://github.com/tidwall/gjson/blob/master/SYNTAX.md),可以使用 [GJSON Playground](https://gjson.dev/) 进行语法测试。
## 常见问题
1. 如果返回的错误为 `error status returned by host: bad argument`,请检查`serviceName`是否正确包含了服务的类型后缀(.dns等)。

View File

@@ -6,6 +6,10 @@ description: AI Cache Plugin Configuration Reference
## Function Description
LLM result caching plugin, the default configuration can be directly used for result caching under the OpenAI protocol, and it supports caching of both streaming and non-streaming responses.
**Tips**
When carrying the request header `x-higress-skip-ai-cache: on`, the current request will not use content from the cache but will be directly forwarded to the backend service. Additionally, the response content from this request will not be cached.
## Runtime Properties
Plugin Execution Phase: `Authentication Phase`
Plugin Execution Priority: `10`

View File

@@ -0,0 +1,135 @@
package cache
import (
"errors"
"github.com/alibaba/higress/plugins/wasm-go/pkg/wrapper"
"github.com/tidwall/gjson"
)
const (
PROVIDER_TYPE_REDIS = "redis"
DEFAULT_CACHE_PREFIX = "higress-ai-cache:"
)
type providerInitializer interface {
ValidateConfig(ProviderConfig) error
CreateProvider(ProviderConfig) (Provider, error)
}
var (
providerInitializers = map[string]providerInitializer{
PROVIDER_TYPE_REDIS: &redisProviderInitializer{},
}
)
type ProviderConfig struct {
// @Title zh-CN redis 缓存服务提供者类型
// @Description zh-CN 缓存服务提供者类型,例如 redis
typ string
// @Title zh-CN redis 缓存服务名称
// @Description zh-CN 缓存服务名称
serviceName string
// @Title zh-CN redis 缓存服务端口
// @Description zh-CN 缓存服务端口默认值为6379
servicePort int
// @Title zh-CN redis 缓存服务地址
// @Description zh-CN Cache 缓存服务地址,非必填
serviceHost string
// @Title zh-CN 缓存服务用户名
// @Description zh-CN 缓存服务用户名,非必填
username string
// @Title zh-CN 缓存服务密码
// @Description zh-CN 缓存服务密码,非必填
password string
// @Title zh-CN 请求超时
// @Description zh-CN 请求缓存服务的超时时间单位为毫秒。默认值是10000即10秒
timeout uint32
// @Title zh-CN 缓存过期时间
// @Description zh-CN 缓存过期时间单位为秒。默认值是0即永不过期
cacheTTL int
// @Title 缓存 Key 前缀
// @Description 缓存 Key 的前缀,默认值为 "higressAiCache:"
cacheKeyPrefix string
}
func (c *ProviderConfig) GetProviderType() string {
return c.typ
}
func (c *ProviderConfig) FromJson(json gjson.Result) {
c.typ = json.Get("type").String()
c.serviceName = json.Get("serviceName").String()
c.servicePort = int(json.Get("servicePort").Int())
if !json.Get("servicePort").Exists() {
c.servicePort = 6379
}
c.serviceHost = json.Get("serviceHost").String()
c.username = json.Get("username").String()
if !json.Get("username").Exists() {
c.username = ""
}
c.password = json.Get("password").String()
if !json.Get("password").Exists() {
c.password = ""
}
c.timeout = uint32(json.Get("timeout").Int())
if !json.Get("timeout").Exists() {
c.timeout = 10000
}
c.cacheTTL = int(json.Get("cacheTTL").Int())
if !json.Get("cacheTTL").Exists() {
c.cacheTTL = 0
// c.cacheTTL = 3600000
}
if json.Get("cacheKeyPrefix").Exists() {
c.cacheKeyPrefix = json.Get("cacheKeyPrefix").String()
} else {
c.cacheKeyPrefix = DEFAULT_CACHE_PREFIX
}
}
func (c *ProviderConfig) ConvertLegacyJson(json gjson.Result) {
c.FromJson(json.Get("redis"))
c.typ = "redis"
if json.Get("cacheTTL").Exists() {
c.cacheTTL = int(json.Get("cacheTTL").Int())
}
}
func (c *ProviderConfig) Validate() error {
if c.typ == "" {
return errors.New("cache service type is required")
}
if c.serviceName == "" {
return errors.New("cache service name is required")
}
if c.cacheTTL < 0 {
return errors.New("cache TTL must be greater than or equal to 0")
}
initializer, has := providerInitializers[c.typ]
if !has {
return errors.New("unknown cache service provider type: " + c.typ)
}
if err := initializer.ValidateConfig(*c); err != nil {
return err
}
return nil
}
func CreateProvider(pc ProviderConfig) (Provider, error) {
initializer, has := providerInitializers[pc.typ]
if !has {
return nil, errors.New("unknown provider type: " + pc.typ)
}
return initializer.CreateProvider(pc)
}
type Provider interface {
GetProviderType() string
Init(username string, password string, timeout uint32) error
Get(key string, cb wrapper.RedisResponseCallback) error
Set(key string, value string, cb wrapper.RedisResponseCallback) error
GetCacheKeyPrefix() string
}

View File

@@ -0,0 +1,58 @@
package cache
import (
"errors"
"github.com/alibaba/higress/plugins/wasm-go/pkg/wrapper"
)
type redisProviderInitializer struct {
}
func (r *redisProviderInitializer) ValidateConfig(cf ProviderConfig) error {
if len(cf.serviceName) == 0 {
return errors.New("cache service name is required")
}
return nil
}
func (r *redisProviderInitializer) CreateProvider(cf ProviderConfig) (Provider, error) {
rp := redisProvider{
config: cf,
client: wrapper.NewRedisClusterClient(wrapper.FQDNCluster{
FQDN: cf.serviceName,
Host: cf.serviceHost,
Port: int64(cf.servicePort)}),
}
err := rp.Init(cf.username, cf.password, cf.timeout)
return &rp, err
}
type redisProvider struct {
config ProviderConfig
client wrapper.RedisClient
}
func (rp *redisProvider) GetProviderType() string {
return PROVIDER_TYPE_REDIS
}
func (rp *redisProvider) Init(username string, password string, timeout uint32) error {
return rp.client.Init(rp.config.username, rp.config.password, int64(rp.config.timeout))
}
func (rp *redisProvider) Get(key string, cb wrapper.RedisResponseCallback) error {
return rp.client.Get(key, cb)
}
func (rp *redisProvider) Set(key string, value string, cb wrapper.RedisResponseCallback) error {
if rp.config.cacheTTL == 0 {
return rp.client.Set(key, value, cb)
} else {
return rp.client.SetEx(key, value, rp.config.cacheTTL, cb)
}
}
func (rp *redisProvider) GetCacheKeyPrefix() string {
return rp.config.cacheKeyPrefix
}

View File

@@ -0,0 +1,225 @@
package config
import (
"fmt"
"github.com/alibaba/higress/plugins/wasm-go/extensions/ai-cache/cache"
"github.com/alibaba/higress/plugins/wasm-go/extensions/ai-cache/embedding"
"github.com/alibaba/higress/plugins/wasm-go/extensions/ai-cache/vector"
"github.com/alibaba/higress/plugins/wasm-go/pkg/wrapper"
"github.com/tidwall/gjson"
)
const (
CACHE_KEY_STRATEGY_LAST_QUESTION = "lastQuestion"
CACHE_KEY_STRATEGY_ALL_QUESTIONS = "allQuestions"
CACHE_KEY_STRATEGY_DISABLED = "disabled"
)
type PluginConfig struct {
// @Title zh-CN 返回 HTTP 响应的模版
// @Description zh-CN 用 %s 标记需要被 cache value 替换的部分
ResponseTemplate string
// @Title zh-CN 返回流式 HTTP 响应的模版
// @Description zh-CN 用 %s 标记需要被 cache value 替换的部分
StreamResponseTemplate string
cacheProvider cache.Provider
embeddingProvider embedding.Provider
vectorProvider vector.Provider
embeddingProviderConfig embedding.ProviderConfig
vectorProviderConfig vector.ProviderConfig
cacheProviderConfig cache.ProviderConfig
CacheKeyFrom string
CacheValueFrom string
CacheStreamValueFrom string
CacheToolCallsFrom string
// @Title zh-CN 启用语义化缓存
// @Description zh-CN 控制是否启用语义化缓存功能。true 表示启用false 表示禁用。
EnableSemanticCache bool
// @Title zh-CN 缓存键策略
// @Description zh-CN 决定如何生成缓存键的策略。可选值: "lastQuestion" (使用最后一个问题), "allQuestions" (拼接所有问题) 或 "disabled" (禁用缓存)
CacheKeyStrategy string
}
func (c *PluginConfig) FromJson(json gjson.Result, log wrapper.Log) {
c.vectorProviderConfig.FromJson(json.Get("vector"))
c.embeddingProviderConfig.FromJson(json.Get("embedding"))
c.cacheProviderConfig.FromJson(json.Get("cache"))
if json.Get("redis").Exists() {
// compatible with legacy config
c.cacheProviderConfig.ConvertLegacyJson(json)
}
c.CacheKeyStrategy = json.Get("cacheKeyStrategy").String()
if c.CacheKeyStrategy == "" {
c.CacheKeyStrategy = CACHE_KEY_STRATEGY_LAST_QUESTION // set default value
}
c.CacheKeyFrom = json.Get("cacheKeyFrom").String()
if c.CacheKeyFrom == "" {
c.CacheKeyFrom = "messages.@reverse.0.content"
}
c.CacheValueFrom = json.Get("cacheValueFrom").String()
if c.CacheValueFrom == "" {
c.CacheValueFrom = "choices.0.message.content"
}
c.CacheStreamValueFrom = json.Get("cacheStreamValueFrom").String()
if c.CacheStreamValueFrom == "" {
c.CacheStreamValueFrom = "choices.0.delta.content"
}
c.CacheToolCallsFrom = json.Get("cacheToolCallsFrom").String()
if c.CacheToolCallsFrom == "" {
c.CacheToolCallsFrom = "choices.0.delta.content.tool_calls"
}
c.StreamResponseTemplate = json.Get("streamResponseTemplate").String()
if c.StreamResponseTemplate == "" {
c.StreamResponseTemplate = `data:{"id":"from-cache","choices":[{"index":0,"delta":{"role":"assistant","content":"%s"},"finish_reason":"stop"}],"model":"gpt-4o","object":"chat.completion","usage":{"prompt_tokens":0,"completion_tokens":0,"total_tokens":0}}` + "\n\ndata:[DONE]\n\n"
}
c.ResponseTemplate = json.Get("responseTemplate").String()
if c.ResponseTemplate == "" {
c.ResponseTemplate = `{"id":"from-cache","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}}`
}
if json.Get("enableSemanticCache").Exists() {
c.EnableSemanticCache = json.Get("enableSemanticCache").Bool()
} else {
c.EnableSemanticCache = true // set default value to true
}
// compatible with legacy config
convertLegacyMapFields(c, json, log)
}
func (c *PluginConfig) Validate() error {
// if cache provider is configured, validate it
if c.cacheProviderConfig.GetProviderType() != "" {
if err := c.cacheProviderConfig.Validate(); err != nil {
return err
}
}
if c.embeddingProviderConfig.GetProviderType() != "" {
if err := c.embeddingProviderConfig.Validate(); err != nil {
return err
}
}
if c.vectorProviderConfig.GetProviderType() != "" {
if err := c.vectorProviderConfig.Validate(); err != nil {
return err
}
}
// cache, vector, and embedding cannot all be empty
if c.vectorProviderConfig.GetProviderType() == "" &&
c.embeddingProviderConfig.GetProviderType() == "" &&
c.cacheProviderConfig.GetProviderType() == "" {
return fmt.Errorf("vector, embedding and cache provider cannot be all empty")
}
// Validate the value of CacheKeyStrategy
if c.CacheKeyStrategy != CACHE_KEY_STRATEGY_LAST_QUESTION &&
c.CacheKeyStrategy != CACHE_KEY_STRATEGY_ALL_QUESTIONS &&
c.CacheKeyStrategy != CACHE_KEY_STRATEGY_DISABLED {
return fmt.Errorf("invalid CacheKeyStrategy: %s", c.CacheKeyStrategy)
}
// If semantic cache is enabled, ensure necessary components are configured
// if c.EnableSemanticCache {
// if c.embeddingProviderConfig.GetProviderType() == "" {
// return fmt.Errorf("semantic cache is enabled but embedding provider is not configured")
// }
// // if only configure cache, just warn the user
// }
return nil
}
func (c *PluginConfig) Complete(log wrapper.Log) error {
var err error
if c.embeddingProviderConfig.GetProviderType() != "" {
log.Debugf("embedding provider is set to %s", c.embeddingProviderConfig.GetProviderType())
c.embeddingProvider, err = embedding.CreateProvider(c.embeddingProviderConfig)
if err != nil {
return err
}
} else {
log.Info("embedding provider is not configured")
c.embeddingProvider = nil
}
if c.cacheProviderConfig.GetProviderType() != "" {
log.Debugf("cache provider is set to %s", c.cacheProviderConfig.GetProviderType())
c.cacheProvider, err = cache.CreateProvider(c.cacheProviderConfig)
if err != nil {
return err
}
} else {
log.Info("cache provider is not configured")
c.cacheProvider = nil
}
if c.vectorProviderConfig.GetProviderType() != "" {
log.Debugf("vector provider is set to %s", c.vectorProviderConfig.GetProviderType())
c.vectorProvider, err = vector.CreateProvider(c.vectorProviderConfig)
if err != nil {
return err
}
} else {
log.Info("vector provider is not configured")
c.vectorProvider = nil
}
return nil
}
func (c *PluginConfig) GetEmbeddingProvider() embedding.Provider {
return c.embeddingProvider
}
func (c *PluginConfig) GetVectorProvider() vector.Provider {
return c.vectorProvider
}
func (c *PluginConfig) GetVectorProviderConfig() vector.ProviderConfig {
return c.vectorProviderConfig
}
func (c *PluginConfig) GetCacheProvider() cache.Provider {
return c.cacheProvider
}
func convertLegacyMapFields(c *PluginConfig, json gjson.Result, log wrapper.Log) {
keyMap := map[string]string{
"cacheKeyFrom.requestBody": "cacheKeyFrom",
"cacheValueFrom.requestBody": "cacheValueFrom",
"cacheStreamValueFrom.requestBody": "cacheStreamValueFrom",
"returnResponseTemplate": "responseTemplate",
"returnStreamResponseTemplate": "streamResponseTemplate",
}
for oldKey, newKey := range keyMap {
if json.Get(oldKey).Exists() {
log.Debugf("[convertLegacyMapFields] mapping %s to %s", oldKey, newKey)
setField(c, newKey, json.Get(oldKey).String(), log)
} else {
log.Debugf("[convertLegacyMapFields] %s not exists", oldKey)
}
}
}
func setField(c *PluginConfig, fieldName string, value string, log wrapper.Log) {
switch fieldName {
case "cacheKeyFrom":
c.CacheKeyFrom = value
case "cacheValueFrom":
c.CacheValueFrom = value
case "cacheStreamValueFrom":
c.CacheStreamValueFrom = value
case "responseTemplate":
c.ResponseTemplate = value
case "streamResponseTemplate":
c.StreamResponseTemplate = value
}
log.Debugf("[setField] set %s to %s", fieldName, value)
}

View File

@@ -0,0 +1,275 @@
package main
import (
"errors"
"fmt"
"strconv"
"strings"
"github.com/alibaba/higress/plugins/wasm-go/extensions/ai-cache/config"
"github.com/alibaba/higress/plugins/wasm-go/extensions/ai-cache/vector"
"github.com/alibaba/higress/plugins/wasm-go/pkg/wrapper"
"github.com/higress-group/proxy-wasm-go-sdk/proxywasm"
"github.com/tidwall/resp"
)
// CheckCacheForKey checks if the key is in the cache, or triggers similarity search if not found.
func CheckCacheForKey(key string, ctx wrapper.HttpContext, c config.PluginConfig, log wrapper.Log, stream bool, useSimilaritySearch bool) error {
activeCacheProvider := c.GetCacheProvider()
if activeCacheProvider == nil {
log.Debugf("[%s] [CheckCacheForKey] no cache provider configured, performing similarity search", PLUGIN_NAME)
return performSimilaritySearch(key, ctx, c, log, key, stream)
}
queryKey := activeCacheProvider.GetCacheKeyPrefix() + key
log.Debugf("[%s] [CheckCacheForKey] querying cache with key: %s", PLUGIN_NAME, queryKey)
err := activeCacheProvider.Get(queryKey, func(response resp.Value) {
handleCacheResponse(key, response, ctx, log, stream, c, useSimilaritySearch)
})
if err != nil {
log.Errorf("[%s] [CheckCacheForKey] failed to retrieve key: %s from cache, error: %v", PLUGIN_NAME, key, err)
return err
}
return nil
}
// handleCacheResponse processes cache response and handles cache hits and misses.
func handleCacheResponse(key string, response resp.Value, ctx wrapper.HttpContext, log wrapper.Log, stream bool, c config.PluginConfig, useSimilaritySearch bool) {
if err := response.Error(); err == nil && !response.IsNull() {
log.Infof("[%s] cache hit for key: %s", PLUGIN_NAME, key)
processCacheHit(key, response.String(), stream, ctx, c, log)
return
}
log.Infof("[%s] [handleCacheResponse] cache miss for key: %s", PLUGIN_NAME, key)
if err := response.Error(); err != nil {
log.Errorf("[%s] [handleCacheResponse] error retrieving key: %s from cache, error: %v", PLUGIN_NAME, key, err)
}
if useSimilaritySearch && c.EnableSemanticCache {
if err := performSimilaritySearch(key, ctx, c, log, key, stream); err != nil {
log.Errorf("[%s] [handleCacheResponse] failed to perform similarity search for key: %s, error: %v", PLUGIN_NAME, key, err)
proxywasm.ResumeHttpRequest()
}
} else {
proxywasm.ResumeHttpRequest()
}
}
// processCacheHit handles a successful cache hit.
func processCacheHit(key string, response string, stream bool, ctx wrapper.HttpContext, c config.PluginConfig, log wrapper.Log) {
if strings.TrimSpace(response) == "" {
log.Warnf("[%s] [processCacheHit] cached response for key %s is empty", PLUGIN_NAME, key)
proxywasm.ResumeHttpRequest()
return
}
log.Debugf("[%s] [processCacheHit] cached response for key %s: %s", PLUGIN_NAME, key, response)
// Escape the response to ensure consistent formatting
escapedResponse := strings.Trim(strconv.Quote(response), "\"")
ctx.SetContext(CACHE_KEY_CONTEXT_KEY, nil)
if stream {
proxywasm.SendHttpResponseWithDetail(200, "ai-cache.hit", [][2]string{{"content-type", "text/event-stream; charset=utf-8"}}, []byte(fmt.Sprintf(c.StreamResponseTemplate, escapedResponse)), -1)
} else {
proxywasm.SendHttpResponseWithDetail(200, "ai-cache.hit", [][2]string{{"content-type", "application/json; charset=utf-8"}}, []byte(fmt.Sprintf(c.ResponseTemplate, escapedResponse)), -1)
}
}
// performSimilaritySearch determines the appropriate similarity search method to use.
func performSimilaritySearch(key string, ctx wrapper.HttpContext, c config.PluginConfig, log wrapper.Log, queryString string, stream bool) error {
activeVectorProvider := c.GetVectorProvider()
if activeVectorProvider == nil {
return logAndReturnError(log, "[performSimilaritySearch] no vector provider configured for similarity search")
}
// Check if the active vector provider implements the StringQuerier interface.
if _, ok := activeVectorProvider.(vector.StringQuerier); ok {
log.Debugf("[%s] [performSimilaritySearch] active vector provider implements StringQuerier interface, performing string query", PLUGIN_NAME)
return performStringQuery(key, queryString, ctx, c, log, stream)
}
// Check if the active vector provider implements the EmbeddingQuerier interface.
if _, ok := activeVectorProvider.(vector.EmbeddingQuerier); ok {
log.Debugf("[%s] [performSimilaritySearch] active vector provider implements EmbeddingQuerier interface, performing embedding query", PLUGIN_NAME)
return performEmbeddingQuery(key, ctx, c, log, stream)
}
return logAndReturnError(log, "[performSimilaritySearch] no suitable querier or embedding provider available for similarity search")
}
// performStringQuery executes the string-based similarity search.
func performStringQuery(key string, queryString string, ctx wrapper.HttpContext, c config.PluginConfig, log wrapper.Log, stream bool) error {
stringQuerier, ok := c.GetVectorProvider().(vector.StringQuerier)
if !ok {
return logAndReturnError(log, "[performStringQuery] active vector provider does not implement StringQuerier interface")
}
return stringQuerier.QueryString(queryString, ctx, log, func(results []vector.QueryResult, ctx wrapper.HttpContext, log wrapper.Log, err error) {
handleQueryResults(key, results, ctx, log, stream, c, err)
})
}
// performEmbeddingQuery executes the embedding-based similarity search.
func performEmbeddingQuery(key string, ctx wrapper.HttpContext, c config.PluginConfig, log wrapper.Log, stream bool) error {
embeddingQuerier, ok := c.GetVectorProvider().(vector.EmbeddingQuerier)
if !ok {
return logAndReturnError(log, fmt.Sprintf("[performEmbeddingQuery] active vector provider does not implement EmbeddingQuerier interface"))
}
activeEmbeddingProvider := c.GetEmbeddingProvider()
if activeEmbeddingProvider == nil {
return logAndReturnError(log, fmt.Sprintf("[performEmbeddingQuery] no embedding provider configured for similarity search"))
}
return activeEmbeddingProvider.GetEmbedding(key, ctx, log, func(textEmbedding []float64, err error) {
log.Debugf("[%s] [performEmbeddingQuery] GetEmbedding success, length of embedding: %d, error: %v", PLUGIN_NAME, len(textEmbedding), err)
if err != nil {
handleInternalError(err, fmt.Sprintf("[%s] [performEmbeddingQuery] error getting embedding for key: %s", PLUGIN_NAME, key), log)
return
}
ctx.SetContext(CACHE_KEY_EMBEDDING_KEY, textEmbedding)
err = embeddingQuerier.QueryEmbedding(textEmbedding, ctx, log, func(results []vector.QueryResult, ctx wrapper.HttpContext, log wrapper.Log, err error) {
handleQueryResults(key, results, ctx, log, stream, c, err)
})
if err != nil {
handleInternalError(err, fmt.Sprintf("[%s] [performEmbeddingQuery] error querying vector database for key: %s", PLUGIN_NAME, key), log)
}
})
}
// handleQueryResults processes the results of similarity search and determines next actions.
func handleQueryResults(key string, results []vector.QueryResult, ctx wrapper.HttpContext, log wrapper.Log, stream bool, c config.PluginConfig, err error) {
if err != nil {
handleInternalError(err, fmt.Sprintf("[%s] [handleQueryResults] error querying vector database for key: %s", PLUGIN_NAME, key), log)
return
}
if len(results) == 0 {
log.Warnf("[%s] [handleQueryResults] no similar keys found for key: %s", PLUGIN_NAME, key)
proxywasm.ResumeHttpRequest()
return
}
mostSimilarData := results[0]
log.Debugf("[%s] [handleQueryResults] for key: %s, the most similar key found: %s with score: %f", PLUGIN_NAME, key, mostSimilarData.Text, mostSimilarData.Score)
simThreshold := c.GetVectorProviderConfig().Threshold
simThresholdRelation := c.GetVectorProviderConfig().ThresholdRelation
if compare(simThresholdRelation, mostSimilarData.Score, simThreshold) {
log.Infof("[%s] key accepted: %s with score: %f", PLUGIN_NAME, mostSimilarData.Text, mostSimilarData.Score)
if mostSimilarData.Answer != "" {
// direct return the answer if available
cacheResponse(ctx, c, key, mostSimilarData.Answer, log)
processCacheHit(key, mostSimilarData.Answer, stream, ctx, c, log)
} else {
if c.GetCacheProvider() != nil {
CheckCacheForKey(mostSimilarData.Text, ctx, c, log, stream, false)
} else {
// Otherwise, do not check the cache, directly return
log.Infof("[%s] cache hit for key: %s, but no corresponding answer found in the vector database", PLUGIN_NAME, mostSimilarData.Text)
proxywasm.ResumeHttpRequest()
}
}
} else {
log.Infof("[%s] score not meet the threshold %f: %s with score %f", PLUGIN_NAME, simThreshold, mostSimilarData.Text, mostSimilarData.Score)
proxywasm.ResumeHttpRequest()
}
}
// logAndReturnError logs an error and returns it.
func logAndReturnError(log wrapper.Log, message string) error {
message = fmt.Sprintf("[%s] %s", PLUGIN_NAME, message)
log.Errorf(message)
return errors.New(message)
}
// handleInternalError logs an error and resumes the HTTP request.
func handleInternalError(err error, message string, log wrapper.Log) {
if err != nil {
log.Errorf("[%s] [handleInternalError] %s: %v", PLUGIN_NAME, message, err)
} else {
log.Errorf("[%s] [handleInternalError] %s", PLUGIN_NAME, message)
}
// proxywasm.SendHttpResponse(500, [][2]string{{"content-type", "text/plain"}}, []byte("Internal Server Error"), -1)
proxywasm.ResumeHttpRequest()
}
// Caches the response value
func cacheResponse(ctx wrapper.HttpContext, c config.PluginConfig, key string, value string, log wrapper.Log) {
if strings.TrimSpace(value) == "" {
log.Warnf("[%s] [cacheResponse] cached value for key %s is empty", PLUGIN_NAME, key)
return
}
activeCacheProvider := c.GetCacheProvider()
if activeCacheProvider != nil {
queryKey := activeCacheProvider.GetCacheKeyPrefix() + key
_ = activeCacheProvider.Set(queryKey, value, nil)
log.Debugf("[%s] [cacheResponse] cache set success, key: %s, length of value: %d", PLUGIN_NAME, queryKey, len(value))
}
}
// Handles embedding upload if available
func uploadEmbeddingAndAnswer(ctx wrapper.HttpContext, c config.PluginConfig, key string, value string, log wrapper.Log) {
embedding := ctx.GetContext(CACHE_KEY_EMBEDDING_KEY)
if embedding == nil {
return
}
emb, ok := embedding.([]float64)
if !ok {
log.Errorf("[%s] [uploadEmbeddingAndAnswer] embedding is not of expected type []float64", PLUGIN_NAME)
return
}
activeVectorProvider := c.GetVectorProvider()
if activeVectorProvider == nil {
log.Debugf("[%s] [uploadEmbeddingAndAnswer] no vector provider configured for uploading embedding", PLUGIN_NAME)
return
}
// Attempt to upload answer embedding first
if ansEmbUploader, ok := activeVectorProvider.(vector.AnswerAndEmbeddingUploader); ok {
log.Infof("[%s] uploading answer embedding for key: %s", PLUGIN_NAME, key)
err := ansEmbUploader.UploadAnswerAndEmbedding(key, emb, value, ctx, log, nil)
if err != nil {
log.Warnf("[%s] [uploadEmbeddingAndAnswer] failed to upload answer embedding for key: %s, error: %v", PLUGIN_NAME, key, err)
} else {
return // If successful, return early
}
}
// If answer embedding upload fails, attempt normal embedding upload
if embUploader, ok := activeVectorProvider.(vector.EmbeddingUploader); ok {
log.Infof("[%s] uploading embedding for key: %s", PLUGIN_NAME, key)
err := embUploader.UploadEmbedding(key, emb, ctx, log, nil)
if err != nil {
log.Warnf("[%s] [uploadEmbeddingAndAnswer] failed to upload embedding for key: %s, error: %v", PLUGIN_NAME, key, err)
}
}
}
// 主要用于相似度/距离/点积判断
// 余弦相似度度量的是两个向量在方向上的相似程度。相似度越高,两个向量越接近。
// 距离度量的是两个向量在空间上的远近程度。距离越小,两个向量越接近。
// compare 函数根据操作符进行判断并返回结果
func compare(operator string, value1 float64, value2 float64) bool {
switch operator {
case "gt":
return value1 > value2
case "gte":
return value1 >= value2
case "lt":
return value1 < value2
case "lte":
return value1 <= value2
default:
return false
}
}

View File

@@ -0,0 +1,187 @@
package embedding
import (
"encoding/json"
"errors"
"fmt"
"net/http"
"strconv"
"github.com/alibaba/higress/plugins/wasm-go/pkg/wrapper"
)
const (
DASHSCOPE_DOMAIN = "dashscope.aliyuncs.com"
DASHSCOPE_PORT = 443
DASHSCOPE_DEFAULT_MODEL_NAME = "text-embedding-v2"
DASHSCOPE_ENDPOINT = "/api/v1/services/embeddings/text-embedding/text-embedding"
)
type dashScopeProviderInitializer struct {
}
func (d *dashScopeProviderInitializer) ValidateConfig(config ProviderConfig) error {
if config.apiKey == "" {
return errors.New("[DashScope] apiKey is required")
}
return nil
}
func (d *dashScopeProviderInitializer) CreateProvider(c ProviderConfig) (Provider, error) {
if c.servicePort == 0 {
c.servicePort = DASHSCOPE_PORT
}
if c.serviceHost == "" {
c.serviceHost = DASHSCOPE_DOMAIN
}
return &DSProvider{
config: c,
client: wrapper.NewClusterClient(wrapper.FQDNCluster{
FQDN: c.serviceName,
Host: c.serviceHost,
Port: int64(c.servicePort),
}),
}, nil
}
func (d *DSProvider) GetProviderType() string {
return PROVIDER_TYPE_DASHSCOPE
}
type Embedding struct {
Embedding []float64 `json:"embedding"`
TextIndex int `json:"text_index"`
}
type Input struct {
Texts []string `json:"texts"`
}
type Params struct {
TextType string `json:"text_type"`
}
type Response struct {
RequestID string `json:"request_id"`
Output Output `json:"output"`
Usage Usage `json:"usage"`
}
type Output struct {
Embeddings []Embedding `json:"embeddings"`
}
type Usage struct {
TotalTokens int `json:"total_tokens"`
}
type EmbeddingRequest struct {
Model string `json:"model"`
Input Input `json:"input"`
Parameters Params `json:"parameters"`
}
type Document struct {
Vector []float64 `json:"vector"`
Fields map[string]string `json:"fields"`
}
type DSProvider struct {
config ProviderConfig
client wrapper.HttpClient
}
func (d *DSProvider) constructParameters(texts []string, log wrapper.Log) (string, [][2]string, []byte, error) {
model := d.config.model
if model == "" {
model = DASHSCOPE_DEFAULT_MODEL_NAME
}
data := EmbeddingRequest{
Model: model,
Input: Input{
Texts: texts,
},
Parameters: Params{
TextType: "query",
},
}
requestBody, err := json.Marshal(data)
if err != nil {
log.Errorf("failed to marshal request data: %v", err)
return "", nil, nil, err
}
if d.config.apiKey == "" {
err := errors.New("dashScopeKey is empty")
log.Errorf("failed to construct headers: %v", err)
return "", nil, nil, err
}
headers := [][2]string{
{"Authorization", "Bearer " + d.config.apiKey},
{"Content-Type", "application/json"},
}
return DASHSCOPE_ENDPOINT, headers, requestBody, err
}
type Result struct {
ID string `json:"id"`
Vector []float64 `json:"vector,omitempty"`
Fields map[string]interface{} `json:"fields"`
Score float64 `json:"score"`
}
func (d *DSProvider) parseTextEmbedding(responseBody []byte) (*Response, error) {
var resp Response
err := json.Unmarshal(responseBody, &resp)
if err != nil {
return nil, err
}
return &resp, nil
}
func (d *DSProvider) GetEmbedding(
queryString string,
ctx wrapper.HttpContext,
log wrapper.Log,
callback func(emb []float64, err error)) error {
embUrl, embHeaders, embRequestBody, err := d.constructParameters([]string{queryString}, log)
if err != nil {
log.Errorf("failed to construct parameters: %v", err)
return err
}
var resp *Response
err = d.client.Post(embUrl, embHeaders, embRequestBody,
func(statusCode int, responseHeaders http.Header, responseBody []byte) {
if statusCode != http.StatusOK {
err = errors.New("failed to get embedding due to status code: " + strconv.Itoa(statusCode))
callback(nil, err)
return
}
log.Debugf("get embedding response: %d, %s", statusCode, responseBody)
resp, err = d.parseTextEmbedding(responseBody)
if err != nil {
err = fmt.Errorf("failed to parse response: %v", err)
callback(nil, err)
return
}
if len(resp.Output.Embeddings) == 0 {
err = errors.New("no embedding found in response")
callback(nil, err)
return
}
callback(resp.Output.Embeddings[0].Embedding, nil)
}, d.config.timeout)
return err
}

View File

@@ -0,0 +1,112 @@
package embedding
import (
"errors"
"github.com/alibaba/higress/plugins/wasm-go/pkg/wrapper"
"github.com/tidwall/gjson"
)
const (
PROVIDER_TYPE_DASHSCOPE = "dashscope"
PROVIDER_TYPE_TEXTIN = "textin"
)
type providerInitializer interface {
ValidateConfig(ProviderConfig) error
CreateProvider(ProviderConfig) (Provider, error)
}
var (
providerInitializers = map[string]providerInitializer{
PROVIDER_TYPE_DASHSCOPE: &dashScopeProviderInitializer{},
PROVIDER_TYPE_TEXTIN: &textInProviderInitializer{},
}
)
type ProviderConfig struct {
// @Title zh-CN 文本特征提取服务提供者类型
// @Description zh-CN 文本特征提取服务提供者类型,例如 DashScope
typ string
// @Title zh-CN DashScope 文本特征提取服务名称
// @Description zh-CN 文本特征提取服务名称
serviceName string
// @Title zh-CN 文本特征提取服务域名
// @Description zh-CN 文本特征提取服务域名
serviceHost string
// @Title zh-CN 文本特征提取服务端口
// @Description zh-CN 文本特征提取服务端口
servicePort int64
// @Title zh-CN 文本特征提取服务 API Key
// @Description zh-CN 文本特征提取服务 API Key
apiKey string
//@Title zh-CN TextIn x-ti-app-id
// @Description zh-CN 仅适用于 TextIn 服务。参考 https://www.textin.com/document/acge_text_embedding
textinAppId string
//@Title zh-CN TextIn x-ti-secret-code
// @Description zh-CN 仅适用于 TextIn 服务。参考 https://www.textin.com/document/acge_text_embedding
textinSecretCode string
//@Title zh-CN TextIn request matryoshka_dim
// @Description zh-CN 仅适用于 TextIn 服务, 指定返回的向量维度。参考 https://www.textin.com/document/acge_text_embedding
textinMatryoshkaDim int
// @Title zh-CN 文本特征提取服务超时时间
// @Description zh-CN 文本特征提取服务超时时间
timeout uint32
// @Title zh-CN 文本特征提取服务使用的模型
// @Description zh-CN 用于文本特征提取的模型名称, 在 DashScope 中默认为 "text-embedding-v1"
model string
}
func (c *ProviderConfig) FromJson(json gjson.Result) {
c.typ = json.Get("type").String()
c.serviceName = json.Get("serviceName").String()
c.serviceHost = json.Get("serviceHost").String()
c.servicePort = json.Get("servicePort").Int()
c.apiKey = json.Get("apiKey").String()
c.textinAppId = json.Get("textinAppId").String()
c.textinSecretCode = json.Get("textinSecretCode").String()
c.textinMatryoshkaDim = int(json.Get("textinMatryoshkaDim").Int())
c.timeout = uint32(json.Get("timeout").Int())
c.model = json.Get("model").String()
if c.timeout == 0 {
c.timeout = 10000
}
}
func (c *ProviderConfig) Validate() error {
if c.serviceName == "" {
return errors.New("embedding service name is required")
}
if c.typ == "" {
return errors.New("embedding service type is required")
}
initializer, has := providerInitializers[c.typ]
if !has {
return errors.New("unknown embedding service provider type: " + c.typ)
}
if err := initializer.ValidateConfig(*c); err != nil {
return err
}
return nil
}
func (c *ProviderConfig) GetProviderType() string {
return c.typ
}
func CreateProvider(pc ProviderConfig) (Provider, error) {
initializer, has := providerInitializers[pc.typ]
if !has {
return nil, errors.New("unknown provider type: " + pc.typ)
}
return initializer.CreateProvider(pc)
}
type Provider interface {
GetProviderType() string
GetEmbedding(
queryString string,
ctx wrapper.HttpContext,
log wrapper.Log,
callback func(emb []float64, err error)) error
}

View File

@@ -0,0 +1,161 @@
package embedding
import (
"encoding/json"
"errors"
"fmt"
"net/http"
"strconv"
"github.com/alibaba/higress/plugins/wasm-go/pkg/wrapper"
)
const (
TEXTIN_DOMAIN = "api.textin.com"
TEXTIN_PORT = 443
TEXTIN_DEFAULT_MODEL_NAME = "acge-text-embedding"
TEXTIN_ENDPOINT = "/ai/service/v1/acge_embedding"
)
type textInProviderInitializer struct {
}
func (t *textInProviderInitializer) ValidateConfig(config ProviderConfig) error {
if config.textinAppId == "" {
return errors.New("embedding service TextIn App ID is required")
}
if config.textinSecretCode == "" {
return errors.New("embedding service TextIn Secret Code is required")
}
if config.textinMatryoshkaDim == 0 {
return errors.New("embedding service TextIn Matryoshka Dim is required")
}
return nil
}
func (t *textInProviderInitializer) CreateProvider(c ProviderConfig) (Provider, error) {
if c.servicePort == 0 {
c.servicePort = TEXTIN_PORT
}
if c.serviceHost == "" {
c.serviceHost = TEXTIN_DOMAIN
}
return &TIProvider{
config: c,
client: wrapper.NewClusterClient(wrapper.FQDNCluster{
FQDN: c.serviceName,
Host: c.serviceHost,
Port: int64(c.servicePort),
}),
}, nil
}
func (t *TIProvider) GetProviderType() string {
return PROVIDER_TYPE_TEXTIN
}
type TextInResponse struct {
Code int `json:"code"`
Message string `json:"message"`
Duration float64 `json:"duration"`
Result TextInResult `json:"result"`
}
type TextInResult struct {
Embeddings [][]float64 `json:"embedding"`
MatryoshkaDim int `json:"matryoshka_dim"`
}
type TextInEmbeddingRequest struct {
Input []string `json:"input"`
MatryoshkaDim int `json:"matryoshka_dim"`
}
type TIProvider struct {
config ProviderConfig
client wrapper.HttpClient
}
func (t *TIProvider) constructParameters(texts []string, log wrapper.Log) (string, [][2]string, []byte, error) {
data := TextInEmbeddingRequest{
Input: texts,
MatryoshkaDim: t.config.textinMatryoshkaDim,
}
requestBody, err := json.Marshal(data)
if err != nil {
log.Errorf("failed to marshal request data: %v", err)
return "", nil, nil, err
}
if t.config.textinAppId == "" {
err := errors.New("textinAppId is empty")
log.Errorf("failed to construct headers: %v", err)
return "", nil, nil, err
}
if t.config.textinSecretCode == "" {
err := errors.New("textinSecretCode is empty")
log.Errorf("failed to construct headers: %v", err)
return "", nil, nil, err
}
headers := [][2]string{
{"x-ti-app-id", t.config.textinAppId},
{"x-ti-secret-code", t.config.textinSecretCode},
{"Content-Type", "application/json"},
}
return TEXTIN_ENDPOINT, headers, requestBody, err
}
func (t *TIProvider) parseTextEmbedding(responseBody []byte) (*TextInResponse, error) {
var resp TextInResponse
err := json.Unmarshal(responseBody, &resp)
if err != nil {
return nil, err
}
return &resp, nil
}
func (t *TIProvider) GetEmbedding(
queryString string,
ctx wrapper.HttpContext,
log wrapper.Log,
callback func(emb []float64, err error)) error {
embUrl, embHeaders, embRequestBody, err := t.constructParameters([]string{queryString}, log)
if err != nil {
log.Errorf("failed to construct parameters: %v", err)
return err
}
var resp *TextInResponse
err = t.client.Post(embUrl, embHeaders, embRequestBody,
func(statusCode int, responseHeaders http.Header, responseBody []byte) {
if statusCode != http.StatusOK {
err = errors.New("failed to get embedding due to status code: " + strconv.Itoa(statusCode))
callback(nil, err)
return
}
log.Debugf("get embedding response: %d, %s", statusCode, responseBody)
resp, err = t.parseTextEmbedding(responseBody)
if err != nil {
err = fmt.Errorf("failed to parse response: %v", err)
callback(nil, err)
return
}
if len(resp.Result.Embeddings) == 0 {
err = errors.New("no embedding found in response")
callback(nil, err)
return
}
callback(resp.Result.Embeddings[0], nil)
}, t.config.timeout)
return err
}

View File

@@ -0,0 +1,27 @@
package embedding
// import (
// "github.com/alibaba/higress/plugins/wasm-go/pkg/wrapper"
// )
// const (
// weaviateURL = "172.17.0.1:8081"
// )
// type weaviateProviderInitializer struct {
// }
// func (d *weaviateProviderInitializer) ValidateConfig(config ProviderConfig) error {
// return nil
// }
// func (d *weaviateProviderInitializer) CreateProvider(config ProviderConfig) (Provider, error) {
// return &DSProvider{
// config: config,
// client: wrapper.NewClusterClient(wrapper.DnsCluster{
// ServiceName: config.ServiceName,
// Port: dashScopePort,
// Domain: dashScopeDomain,
// }),
// }, nil
// }

View File

@@ -7,17 +7,18 @@ go 1.19
replace github.com/alibaba/higress/plugins/wasm-go => ../..
require (
github.com/alibaba/higress/plugins/wasm-go v1.3.6-0.20240528060522-53bccf89f441
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.14.3
github.com/tidwall/gjson v1.17.3
github.com/tidwall/resp v0.1.1
github.com/tidwall/sjson v1.2.5
// github.com/weaviate/weaviate-go-client/v4 v4.15.1
)
require (
github.com/google/uuid v1.3.0 // indirect
github.com/google/uuid v1.6.0 // indirect
github.com/higress-group/nottinygc v0.0.0-20231101025119-e93c4c2f8520 // indirect
github.com/magefile/mage v1.14.0 // indirect
github.com/stretchr/testify v1.9.0 // indirect
github.com/tidwall/match v1.1.1 // indirect
github.com/tidwall/pretty v1.2.0 // indirect
)

View File

@@ -1,24 +1,21 @@
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/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.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=
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.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/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.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=
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/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=

View File

@@ -1,32 +1,33 @@
// File generated by hgctl. Modify as required.
// See: https://higress.io/zh-cn/docs/user/wasm-go#2-%E7%BC%96%E5%86%99-maingo-%E6%96%87%E4%BB%B6
// 这个文件中主要将OnHttpRequestHeaders、OnHttpRequestBody、OnHttpResponseHeaders、OnHttpResponseBody这四个函数实现
// 其中的缓存思路调用cache.go中的逻辑然后cache.go中的逻辑会调用textEmbeddingProvider和vectorStoreProvider中的逻辑实例
package main
import (
"errors"
"fmt"
"strings"
"github.com/alibaba/higress/plugins/wasm-go/extensions/ai-cache/config"
"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 (
CacheKeyContextKey = "cacheKey"
CacheContentContextKey = "cacheContent"
PartialMessageContextKey = "partialMessage"
ToolCallsContextKey = "toolCalls"
StreamContextKey = "stream"
DefaultCacheKeyPrefix = "higress-ai-cache:"
PLUGIN_NAME = "ai-cache"
CACHE_KEY_CONTEXT_KEY = "cacheKey"
CACHE_KEY_EMBEDDING_KEY = "cacheKeyEmbedding"
CACHE_CONTENT_CONTEXT_KEY = "cacheContent"
PARTIAL_MESSAGE_CONTEXT_KEY = "partialMessage"
TOOL_CALLS_CONTEXT_KEY = "toolCalls"
STREAM_CONTEXT_KEY = "stream"
SKIP_CACHE_HEADER = "x-higress-skip-ai-cache"
ERROR_PARTIAL_MESSAGE_KEY = "errorPartialMessage"
)
func main() {
// CreateClient()
wrapper.SetCtx(
"ai-cache",
PLUGIN_NAME,
wrapper.ParseConfigBy(parseConfig),
wrapper.ProcessRequestHeadersBy(onHttpRequestHeaders),
wrapper.ProcessRequestBodyBy(onHttpRequestBody),
@@ -35,337 +36,152 @@ func main() {
)
}
// @Name ai-cache
// @Category protocol
// @Phase AUTHN
// @Priority 10
// @Title zh-CN AI Cache
// @Description zh-CN 大模型结果缓存
// @IconUrl
// @Version 0.1.0
//
// @Contact.name johnlanni
// @Contact.url
// @Contact.email
//
// @Example
// redis:
// serviceName: my-redis.dns
// timeout: 2000
// cacheKeyFrom:
// requestBody: "messages.@reverse.0.content"
// cacheValueFrom:
// responseBody: "choices.0.message.content"
// cacheStreamValueFrom:
// responseBody: "choices.0.delta.content"
// returnResponseTemplate: |
// {"id":"from-cache","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}}
// returnStreamResponseTemplate: |
// data:{"id":"from-cache","choices":[{"index":0,"delta":{"role":"assistant","content":"%s"},"finish_reason":"stop"}],"model":"gpt-4o","object":"chat.completion","usage":{"prompt_tokens":0,"completion_tokens":0,"total_tokens":0}}
//
// data:[DONE]
//
// @End
type RedisInfo struct {
// @Title zh-CN redis 服务名称
// @Description zh-CN 带服务类型的完整 FQDN 名称,例如 my-redis.dns、redis.my-ns.svc.cluster.local
ServiceName string `required:"true" yaml:"serviceName" json:"serviceName"`
// @Title zh-CN redis 服务端口
// @Description zh-CN 默认值为6379
ServicePort int `required:"false" yaml:"servicePort" json:"servicePort"`
// @Title zh-CN 用户名
// @Description zh-CN 登陆 redis 的用户名,非必填
Username string `required:"false" yaml:"username" json:"username"`
// @Title zh-CN 密码
// @Description zh-CN 登陆 redis 的密码,非必填,可以只填密码
Password string `required:"false" yaml:"password" json:"password"`
// @Title zh-CN 请求超时
// @Description zh-CN 请求 redis 的超时时间单位为毫秒。默认值是1000即1秒
Timeout int `required:"false" yaml:"timeout" json:"timeout"`
func parseConfig(json gjson.Result, c *config.PluginConfig, log wrapper.Log) error {
// config.EmbeddingProviderConfig.FromJson(json.Get("embeddingProvider"))
// config.VectorDatabaseProviderConfig.FromJson(json.Get("vectorBaseProvider"))
// config.RedisConfig.FromJson(json.Get("redis"))
c.FromJson(json, log)
if err := c.Validate(); err != nil {
return err
}
// Note that initializing the client during the parseConfig phase may cause errors, such as Redis not being usable in Docker Compose.
if err := c.Complete(log); err != nil {
log.Errorf("complete config failed: %v", err)
return err
}
return nil
}
type KVExtractor struct {
// @Title zh-CN 从请求 Body 中基于 [GJSON PATH](https://github.com/tidwall/gjson/blob/master/SYNTAX.md) 语法提取字符串
RequestBody string `required:"false" yaml:"requestBody" json:"requestBody"`
// @Title zh-CN 从响应 Body 中基于 [GJSON PATH](https://github.com/tidwall/gjson/blob/master/SYNTAX.md) 语法提取字符串
ResponseBody string `required:"false" yaml:"responseBody" json:"responseBody"`
}
type PluginConfig struct {
// @Title zh-CN Redis 地址信息
// @Description zh-CN 用于存储缓存结果的 Redis 地址
RedisInfo RedisInfo `required:"true" yaml:"redis" json:"redis"`
// @Title zh-CN 缓存 key 的来源
// @Description zh-CN 往 redis 里存时,使用的 key 的提取方式
CacheKeyFrom KVExtractor `required:"true" yaml:"cacheKeyFrom" json:"cacheKeyFrom"`
// @Title zh-CN 缓存 value 的来源
// @Description zh-CN 往 redis 里存时,使用的 value 的提取方式
CacheValueFrom KVExtractor `required:"true" yaml:"cacheValueFrom" json:"cacheValueFrom"`
// @Title zh-CN 流式响应下,缓存 value 的来源
// @Description zh-CN 往 redis 里存时,使用的 value 的提取方式
CacheStreamValueFrom KVExtractor `required:"true" yaml:"cacheStreamValueFrom" json:"cacheStreamValueFrom"`
// @Title zh-CN 返回 HTTP 响应的模版
// @Description zh-CN 用 %s 标记需要被 cache value 替换的部分
ReturnResponseTemplate string `required:"true" yaml:"returnResponseTemplate" json:"returnResponseTemplate"`
// @Title zh-CN 返回流式 HTTP 响应的模版
// @Description zh-CN 用 %s 标记需要被 cache value 替换的部分
ReturnStreamResponseTemplate string `required:"true" yaml:"returnStreamResponseTemplate" json:"returnStreamResponseTemplate"`
// @Title zh-CN 缓存的过期时间
// @Description zh-CN 单位是秒默认值为0即永不过期
CacheTTL int `required:"false" yaml:"cacheTTL" json:"cacheTTL"`
// @Title zh-CN Redis缓存Key的前缀
// @Description zh-CN 默认值是"higress-ai-cache:"
CacheKeyPrefix string `required:"false" yaml:"cacheKeyPrefix" json:"cacheKeyPrefix"`
redisClient wrapper.RedisClient `yaml:"-" json:"-"`
}
func parseConfig(json gjson.Result, c *PluginConfig, log wrapper.Log) error {
c.RedisInfo.ServiceName = json.Get("redis.serviceName").String()
if c.RedisInfo.ServiceName == "" {
return errors.New("redis service name must not by empty")
func onHttpRequestHeaders(ctx wrapper.HttpContext, c config.PluginConfig, log wrapper.Log) types.Action {
skipCache, _ := proxywasm.GetHttpRequestHeader(SKIP_CACHE_HEADER)
if skipCache == "on" {
ctx.SetContext(SKIP_CACHE_HEADER, struct{}{})
ctx.DontReadRequestBody()
return types.ActionContinue
}
c.RedisInfo.ServicePort = int(json.Get("redis.servicePort").Int())
if c.RedisInfo.ServicePort == 0 {
if strings.HasSuffix(c.RedisInfo.ServiceName, ".static") {
// use default logic port which is 80 for static service
c.RedisInfo.ServicePort = 80
} else {
c.RedisInfo.ServicePort = 6379
}
}
c.RedisInfo.Username = json.Get("redis.username").String()
c.RedisInfo.Password = json.Get("redis.password").String()
c.RedisInfo.Timeout = int(json.Get("redis.timeout").Int())
if c.RedisInfo.Timeout == 0 {
c.RedisInfo.Timeout = 1000
}
c.CacheKeyFrom.RequestBody = json.Get("cacheKeyFrom.requestBody").String()
if c.CacheKeyFrom.RequestBody == "" {
c.CacheKeyFrom.RequestBody = "messages.@reverse.0.content"
}
c.CacheValueFrom.ResponseBody = json.Get("cacheValueFrom.responseBody").String()
if c.CacheValueFrom.ResponseBody == "" {
c.CacheValueFrom.ResponseBody = "choices.0.message.content"
}
c.CacheStreamValueFrom.ResponseBody = json.Get("cacheStreamValueFrom.responseBody").String()
if c.CacheStreamValueFrom.ResponseBody == "" {
c.CacheStreamValueFrom.ResponseBody = "choices.0.delta.content"
}
c.ReturnResponseTemplate = json.Get("returnResponseTemplate").String()
if c.ReturnResponseTemplate == "" {
c.ReturnResponseTemplate = `{"id":"from-cache","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}}`
}
c.ReturnStreamResponseTemplate = json.Get("returnStreamResponseTemplate").String()
if c.ReturnStreamResponseTemplate == "" {
c.ReturnStreamResponseTemplate = `data:{"id":"from-cache","choices":[{"index":0,"delta":{"role":"assistant","content":"%s"},"finish_reason":"stop"}],"model":"gpt-4o","object":"chat.completion","usage":{"prompt_tokens":0,"completion_tokens":0,"total_tokens":0}}` + "\n\ndata:[DONE]\n\n"
}
c.CacheKeyPrefix = json.Get("cacheKeyPrefix").String()
if c.CacheKeyPrefix == "" {
c.CacheKeyPrefix = DefaultCacheKeyPrefix
}
c.redisClient = wrapper.NewRedisClusterClient(wrapper.FQDNCluster{
FQDN: c.RedisInfo.ServiceName,
Port: int64(c.RedisInfo.ServicePort),
})
return c.redisClient.Init(c.RedisInfo.Username, c.RedisInfo.Password, int64(c.RedisInfo.Timeout))
}
func onHttpRequestHeaders(ctx wrapper.HttpContext, config PluginConfig, log wrapper.Log) types.Action {
contentType, _ := proxywasm.GetHttpRequestHeader("content-type")
// The request does not have a body.
if contentType == "" {
return types.ActionContinue
}
if !strings.Contains(contentType, "application/json") {
log.Warnf("content is not json, can't process:%s", contentType)
log.Warnf("content is not json, can't process: %s", contentType)
ctx.DontReadRequestBody()
return types.ActionContinue
}
proxywasm.RemoveHttpRequestHeader("Accept-Encoding")
_ = proxywasm.RemoveHttpRequestHeader("Accept-Encoding")
// The request has a body and requires delaying the header transmission until a cache miss occurs,
// at which point the header should be sent.
return types.HeaderStopIteration
}
func TrimQuote(source string) string {
return strings.Trim(source, `"`)
}
func onHttpRequestBody(ctx wrapper.HttpContext, c config.PluginConfig, body []byte, log wrapper.Log) types.Action {
func onHttpRequestBody(ctx wrapper.HttpContext, config PluginConfig, body []byte, log wrapper.Log) types.Action {
bodyJson := gjson.ParseBytes(body)
// TODO: It may be necessary to support stream mode determination for different LLM providers.
stream := false
if bodyJson.Get("stream").Bool() {
stream = true
ctx.SetContext(StreamContextKey, struct{}{})
} else if ctx.GetContext(StreamContextKey) != nil {
stream = true
ctx.SetContext(STREAM_CONTEXT_KEY, struct{}{})
}
key := TrimQuote(bodyJson.Get(config.CacheKeyFrom.RequestBody).Raw)
var key string
if c.CacheKeyStrategy == config.CACHE_KEY_STRATEGY_LAST_QUESTION {
log.Debugf("[onHttpRequestBody] cache key strategy is last question, cache key from: %s", c.CacheKeyFrom)
key = bodyJson.Get(c.CacheKeyFrom).String()
} else if c.CacheKeyStrategy == config.CACHE_KEY_STRATEGY_ALL_QUESTIONS {
log.Debugf("[onHttpRequestBody] cache key strategy is all questions, cache key from: messages")
messages := bodyJson.Get("messages").Array()
var userMessages []string
for _, msg := range messages {
if msg.Get("role").String() == "user" {
userMessages = append(userMessages, msg.Get("content").String())
}
}
key = strings.Join(userMessages, "\n")
} else if c.CacheKeyStrategy == config.CACHE_KEY_STRATEGY_DISABLED {
log.Info("[onHttpRequestBody] cache key strategy is disabled")
ctx.DontReadRequestBody()
return types.ActionContinue
} else {
log.Warnf("[onHttpRequestBody] unknown cache key strategy: %s", c.CacheKeyStrategy)
ctx.DontReadRequestBody()
return types.ActionContinue
}
ctx.SetContext(CACHE_KEY_CONTEXT_KEY, key)
log.Debugf("[onHttpRequestBody] key: %s", key)
if key == "" {
log.Debug("parse key from request body failed")
log.Debug("[onHttpRequestBody] parse key from request body failed")
ctx.DontReadResponseBody()
return types.ActionContinue
}
ctx.SetContext(CacheKeyContextKey, key)
err := config.redisClient.Get(config.CacheKeyPrefix+key, func(response resp.Value) {
if err := response.Error(); err != nil {
log.Errorf("redis get key:%s failed, err:%v", key, err)
proxywasm.ResumeHttpRequest()
return
}
if response.IsNull() {
log.Debugf("cache miss, key:%s", key)
proxywasm.ResumeHttpRequest()
return
}
log.Debugf("cache hit, key:%s", key)
ctx.SetContext(CacheKeyContextKey, nil)
if !stream {
proxywasm.SendHttpResponseWithDetail(200, "ai-cache.hit", [][2]string{{"content-type", "application/json; charset=utf-8"}}, []byte(fmt.Sprintf(config.ReturnResponseTemplate, response.String())), -1)
} else {
proxywasm.SendHttpResponseWithDetail(200, "ai-cache.hit", [][2]string{{"content-type", "text/event-stream; charset=utf-8"}}, []byte(fmt.Sprintf(config.ReturnStreamResponseTemplate, response.String())), -1)
}
})
if err != nil {
log.Error("redis access failed")
if err := CheckCacheForKey(key, ctx, c, log, stream, true); err != nil {
log.Errorf("[onHttpRequestBody] check cache for key: %s failed, error: %v", key, err)
return types.ActionContinue
}
return types.ActionPause
}
func processSSEMessage(ctx wrapper.HttpContext, config PluginConfig, sseMessage string, log wrapper.Log) string {
subMessages := strings.Split(sseMessage, "\n")
var message string
for _, msg := range subMessages {
if strings.HasPrefix(msg, "data:") {
message = msg
break
}
func onHttpResponseHeaders(ctx wrapper.HttpContext, c config.PluginConfig, log wrapper.Log) types.Action {
skipCache := ctx.GetContext(SKIP_CACHE_HEADER)
if skipCache != nil {
ctx.DontReadResponseBody()
return types.ActionContinue
}
if len(message) < 6 {
log.Errorf("invalid message:%s", message)
return ""
}
// skip the prefix "data:"
bodyJson := message[5:]
if gjson.Get(bodyJson, config.CacheStreamValueFrom.ResponseBody).Exists() {
tempContentI := ctx.GetContext(CacheContentContextKey)
if tempContentI == nil {
content := TrimQuote(gjson.Get(bodyJson, config.CacheStreamValueFrom.ResponseBody).Raw)
ctx.SetContext(CacheContentContextKey, content)
return content
}
append := TrimQuote(gjson.Get(bodyJson, config.CacheStreamValueFrom.ResponseBody).Raw)
content := tempContentI.(string) + append
ctx.SetContext(CacheContentContextKey, content)
return content
} else if gjson.Get(bodyJson, "choices.0.delta.content.tool_calls").Exists() {
// TODO: compatible with other providers
ctx.SetContext(ToolCallsContextKey, struct{}{})
return ""
}
log.Debugf("unknown message:%s", bodyJson)
return ""
}
func onHttpResponseHeaders(ctx wrapper.HttpContext, config PluginConfig, log wrapper.Log) types.Action {
contentType, _ := proxywasm.GetHttpResponseHeader("content-type")
if strings.Contains(contentType, "text/event-stream") {
ctx.SetContext(StreamContextKey, struct{}{})
ctx.SetContext(STREAM_CONTEXT_KEY, struct{}{})
}
if ctx.GetContext(ERROR_PARTIAL_MESSAGE_KEY) != nil {
ctx.DontReadResponseBody()
return types.ActionContinue
}
return types.ActionContinue
}
func onHttpResponseBody(ctx wrapper.HttpContext, config PluginConfig, chunk []byte, isLastChunk bool, log wrapper.Log) []byte {
if ctx.GetContext(ToolCallsContextKey) != nil {
// we should not cache tool call result
return chunk
}
keyI := ctx.GetContext(CacheKeyContextKey)
if keyI == nil {
return chunk
}
if !isLastChunk {
stream := ctx.GetContext(StreamContextKey)
if stream == nil {
tempContentI := ctx.GetContext(CacheContentContextKey)
if tempContentI == nil {
ctx.SetContext(CacheContentContextKey, chunk)
return chunk
}
tempContent := tempContentI.([]byte)
tempContent = append(tempContent, chunk...)
ctx.SetContext(CacheContentContextKey, tempContent)
} else {
var partialMessage []byte
partialMessageI := ctx.GetContext(PartialMessageContextKey)
if partialMessageI != nil {
partialMessage = append(partialMessageI.([]byte), chunk...)
} else {
partialMessage = chunk
}
messages := strings.Split(string(partialMessage), "\n\n")
for i, msg := range messages {
if i < len(messages)-1 {
// process complete message
processSSEMessage(ctx, config, msg, log)
}
}
if !strings.HasSuffix(string(partialMessage), "\n\n") {
ctx.SetContext(PartialMessageContextKey, []byte(messages[len(messages)-1]))
} else {
ctx.SetContext(PartialMessageContextKey, nil)
}
}
return chunk
}
// last chunk
key := keyI.(string)
stream := ctx.GetContext(StreamContextKey)
var value string
if stream == nil {
var body []byte
tempContentI := ctx.GetContext(CacheContentContextKey)
if tempContentI != nil {
body = append(tempContentI.([]byte), chunk...)
} else {
body = chunk
}
bodyJson := gjson.ParseBytes(body)
func onHttpResponseBody(ctx wrapper.HttpContext, c config.PluginConfig, chunk []byte, isLastChunk bool, log wrapper.Log) []byte {
log.Debugf("[onHttpResponseBody] is last chunk: %v", isLastChunk)
log.Debugf("[onHttpResponseBody] chunk: %s", string(chunk))
value = TrimQuote(bodyJson.Get(config.CacheValueFrom.ResponseBody).Raw)
if value == "" {
log.Warnf("parse value from response body failded, body:%s", body)
return chunk
if ctx.GetContext(TOOL_CALLS_CONTEXT_KEY) != nil {
return chunk
}
key := ctx.GetContext(CACHE_KEY_CONTEXT_KEY)
if key == nil {
log.Debug("[onHttpResponseBody] key is nil, skip cache")
return chunk
}
if !isLastChunk {
if err := handleNonLastChunk(ctx, c, chunk, log); err != nil {
log.Errorf("[onHttpResponseBody] handle non last chunk failed, error: %v", err)
// Set an empty struct in the context to indicate an error in processing the partial message
ctx.SetContext(ERROR_PARTIAL_MESSAGE_KEY, struct{}{})
}
return chunk
}
stream := ctx.GetContext(STREAM_CONTEXT_KEY)
var value string
var err error
if stream == nil {
value, err = processNonStreamLastChunk(ctx, c, chunk, log)
} else {
if len(chunk) > 0 {
var lastMessage []byte
partialMessageI := ctx.GetContext(PartialMessageContextKey)
if partialMessageI != nil {
lastMessage = append(partialMessageI.([]byte), chunk...)
} else {
lastMessage = chunk
}
if !strings.HasSuffix(string(lastMessage), "\n\n") {
log.Warnf("invalid lastMessage:%s", lastMessage)
return chunk
}
// remove the last \n\n
lastMessage = lastMessage[:len(lastMessage)-2]
value = processSSEMessage(ctx, config, string(lastMessage), log)
} else {
tempContentI := ctx.GetContext(CacheContentContextKey)
if tempContentI == nil {
return chunk
}
value = tempContentI.(string)
}
value, err = processStreamLastChunk(ctx, c, chunk, log)
}
config.redisClient.Set(config.CacheKeyPrefix+key, value, nil)
if config.CacheTTL != 0 {
config.redisClient.Expire(config.CacheKeyPrefix+key, config.CacheTTL, nil)
if err != nil {
log.Errorf("[onHttpResponseBody] process last chunk failed, error: %v", err)
return chunk
}
cacheResponse(ctx, c, key.(string), value, log)
uploadEmbeddingAndAnswer(ctx, c, key.(string), value, log)
return chunk
}

View File

@@ -0,0 +1,155 @@
package main
import (
"fmt"
"strings"
"github.com/alibaba/higress/plugins/wasm-go/extensions/ai-cache/config"
"github.com/alibaba/higress/plugins/wasm-go/pkg/wrapper"
"github.com/tidwall/gjson"
)
func handleNonLastChunk(ctx wrapper.HttpContext, c config.PluginConfig, chunk []byte, log wrapper.Log) error {
stream := ctx.GetContext(STREAM_CONTEXT_KEY)
err := error(nil)
if stream == nil {
err = handleNonStreamChunk(ctx, c, chunk, log)
} else {
err = handleStreamChunk(ctx, c, chunk, log)
}
return err
}
func handleNonStreamChunk(ctx wrapper.HttpContext, c config.PluginConfig, chunk []byte, log wrapper.Log) error {
tempContentI := ctx.GetContext(CACHE_CONTENT_CONTEXT_KEY)
if tempContentI == nil {
ctx.SetContext(CACHE_CONTENT_CONTEXT_KEY, chunk)
return nil
}
tempContent := tempContentI.([]byte)
tempContent = append(tempContent, chunk...)
ctx.SetContext(CACHE_CONTENT_CONTEXT_KEY, tempContent)
return nil
}
func handleStreamChunk(ctx wrapper.HttpContext, c config.PluginConfig, chunk []byte, log wrapper.Log) error {
var partialMessage []byte
partialMessageI := ctx.GetContext(PARTIAL_MESSAGE_CONTEXT_KEY)
log.Debugf("[handleStreamChunk] cache content: %v", ctx.GetContext(CACHE_CONTENT_CONTEXT_KEY))
if partialMessageI != nil {
partialMessage = append(partialMessageI.([]byte), chunk...)
} else {
partialMessage = chunk
}
messages := strings.Split(string(partialMessage), "\n\n")
for i, msg := range messages {
if i < len(messages)-1 {
_, err := processSSEMessage(ctx, c, msg, log)
if err != nil {
return fmt.Errorf("[handleStreamChunk] processSSEMessage failed, error: %v", err)
}
}
}
if !strings.HasSuffix(string(partialMessage), "\n\n") {
ctx.SetContext(PARTIAL_MESSAGE_CONTEXT_KEY, []byte(messages[len(messages)-1]))
} else {
ctx.SetContext(PARTIAL_MESSAGE_CONTEXT_KEY, nil)
}
return nil
}
func processNonStreamLastChunk(ctx wrapper.HttpContext, c config.PluginConfig, chunk []byte, log wrapper.Log) (string, error) {
var body []byte
tempContentI := ctx.GetContext(CACHE_CONTENT_CONTEXT_KEY)
if tempContentI != nil {
body = append(tempContentI.([]byte), chunk...)
} else {
body = chunk
}
bodyJson := gjson.ParseBytes(body)
value := bodyJson.Get(c.CacheValueFrom).String()
if strings.TrimSpace(value) == "" {
return "", fmt.Errorf("[processNonStreamLastChunk] parse value from response body failed, body:%s", body)
}
return value, nil
}
func processStreamLastChunk(ctx wrapper.HttpContext, c config.PluginConfig, chunk []byte, log wrapper.Log) (string, error) {
if len(chunk) > 0 {
var lastMessage []byte
partialMessageI := ctx.GetContext(PARTIAL_MESSAGE_CONTEXT_KEY)
if partialMessageI != nil {
lastMessage = append(partialMessageI.([]byte), chunk...)
} else {
lastMessage = chunk
}
if !strings.HasSuffix(string(lastMessage), "\n\n") {
return "", fmt.Errorf("[processStreamLastChunk] invalid lastMessage:%s", lastMessage)
}
lastMessage = lastMessage[:len(lastMessage)-2]
value, err := processSSEMessage(ctx, c, string(lastMessage), log)
if err != nil {
return "", fmt.Errorf("[processStreamLastChunk] processSSEMessage failed, error: %v", err)
}
return value, nil
}
tempContentI := ctx.GetContext(CACHE_CONTENT_CONTEXT_KEY)
if tempContentI == nil {
return "", nil
}
return tempContentI.(string), nil
}
func processSSEMessage(ctx wrapper.HttpContext, c config.PluginConfig, sseMessage string, log wrapper.Log) (string, error) {
subMessages := strings.Split(sseMessage, "\n")
var message string
for _, msg := range subMessages {
if strings.HasPrefix(msg, "data:") {
message = msg
break
}
}
if len(message) < 6 {
return "", fmt.Errorf("[processSSEMessage] invalid message: %s", message)
}
// skip the prefix "data:"
bodyJson := message[5:]
if strings.TrimSpace(bodyJson) == "[DONE]" {
return "", nil
}
// Extract values from JSON fields
responseBody := gjson.Get(bodyJson, c.CacheStreamValueFrom)
toolCalls := gjson.Get(bodyJson, c.CacheToolCallsFrom)
if toolCalls.Exists() {
// TODO: Temporarily store the tool_calls value in the context for processing
ctx.SetContext(TOOL_CALLS_CONTEXT_KEY, toolCalls.String())
}
// Check if the ResponseBody field exists
if !responseBody.Exists() {
if ctx.GetContext(CACHE_CONTENT_CONTEXT_KEY) != nil {
log.Debugf("[processSSEMessage] unable to extract content from message; cache content is not nil: %s", message)
return "", nil
}
return "", fmt.Errorf("[processSSEMessage] unable to extract content from message; cache content is nil: %s", message)
} else {
tempContentI := ctx.GetContext(CACHE_CONTENT_CONTEXT_KEY)
// If there is no content in the cache, initialize and set the content
if tempContentI == nil {
content := responseBody.String()
ctx.SetContext(CACHE_CONTENT_CONTEXT_KEY, content)
return content, nil
}
// Update the content in the cache
appendMsg := responseBody.String()
content := tempContentI.(string) + appendMsg
ctx.SetContext(CACHE_CONTENT_CONTEXT_KEY, content)
return content, nil
}
}

View File

@@ -0,0 +1,256 @@
package vector
import (
"encoding/json"
"errors"
"fmt"
"net/http"
"github.com/alibaba/higress/plugins/wasm-go/pkg/wrapper"
)
type dashVectorProviderInitializer struct {
}
func (d *dashVectorProviderInitializer) ValidateConfig(config ProviderConfig) error {
if len(config.apiKey) == 0 {
return errors.New("[DashVector] apiKey is required")
}
if len(config.collectionID) == 0 {
return errors.New("[DashVector] collectionID is required")
}
if len(config.serviceName) == 0 {
return errors.New("[DashVector] serviceName is required")
}
if len(config.serviceHost) == 0 {
return errors.New("[DashVector] serviceHost is required")
}
return nil
}
func (d *dashVectorProviderInitializer) CreateProvider(config ProviderConfig) (Provider, error) {
return &DvProvider{
config: config,
client: wrapper.NewClusterClient(wrapper.FQDNCluster{
FQDN: config.serviceName,
Host: config.serviceHost,
Port: int64(config.servicePort),
}),
}, nil
}
type DvProvider struct {
config ProviderConfig
client wrapper.HttpClient
}
func (d *DvProvider) GetProviderType() string {
return PROVIDER_TYPE_DASH_VECTOR
}
// type embeddingRequest struct {
// Model string `json:"model"`
// Input input `json:"input"`
// Parameters params `json:"parameters"`
// }
// type params struct {
// TextType string `json:"text_type"`
// }
// type input struct {
// Texts []string `json:"texts"`
// }
// queryResponse 定义查询响应的结构
type queryResponse struct {
Code int `json:"code"`
RequestID string `json:"request_id"`
Message string `json:"message"`
Output []result `json:"output"`
}
// queryRequest 定义查询请求的结构
type queryRequest struct {
Vector []float64 `json:"vector"`
TopK int `json:"topk"`
IncludeVector bool `json:"include_vector"`
}
// result 定义查询结果的结构
type result struct {
ID string `json:"id"`
Vector []float64 `json:"vector,omitempty"` // omitempty 使得如果 vector 是空,它将不会被序列化
Fields map[string]interface{} `json:"fields"`
Score float64 `json:"score"`
}
func (d *DvProvider) constructEmbeddingQueryParameters(vector []float64) (string, []byte, [][2]string, error) {
url := fmt.Sprintf("/v1/collections/%s/query", d.config.collectionID)
requestData := queryRequest{
Vector: vector,
TopK: d.config.topK,
IncludeVector: false,
}
requestBody, err := json.Marshal(requestData)
if err != nil {
return "", nil, nil, err
}
header := [][2]string{
{"Content-Type", "application/json"},
{"dashvector-auth-token", d.config.apiKey},
}
return url, requestBody, header, nil
}
func (d *DvProvider) parseQueryResponse(responseBody []byte) (queryResponse, error) {
var queryResp queryResponse
err := json.Unmarshal(responseBody, &queryResp)
if err != nil {
return queryResponse{}, err
}
return queryResp, nil
}
func (d *DvProvider) QueryEmbedding(
emb []float64,
ctx wrapper.HttpContext,
log wrapper.Log,
callback func(results []QueryResult, ctx wrapper.HttpContext, log wrapper.Log, err error)) error {
url, body, headers, err := d.constructEmbeddingQueryParameters(emb)
log.Debugf("url:%s, body:%s, headers:%v", url, string(body), headers)
if err != nil {
err = fmt.Errorf("failed to construct embedding query parameters: %v", err)
return err
}
err = d.client.Post(url, headers, body,
func(statusCode int, responseHeaders http.Header, responseBody []byte) {
err = nil
if statusCode != http.StatusOK {
err = fmt.Errorf("failed to query embedding: %d", statusCode)
callback(nil, ctx, log, err)
return
}
log.Debugf("query embedding response: %d, %s", statusCode, responseBody)
results, err := d.ParseQueryResponse(responseBody, ctx, log)
if err != nil {
err = fmt.Errorf("failed to parse query response: %v", err)
}
callback(results, ctx, log, err)
},
d.config.timeout)
if err != nil {
err = fmt.Errorf("failed to query embedding: %v", err)
}
return err
}
func getStringValue(fields map[string]interface{}, key string) string {
if val, ok := fields[key]; ok {
return val.(string)
}
return ""
}
func (d *DvProvider) ParseQueryResponse(responseBody []byte, ctx wrapper.HttpContext, log wrapper.Log) ([]QueryResult, error) {
resp, err := d.parseQueryResponse(responseBody)
if err != nil {
return nil, err
}
if len(resp.Output) == 0 {
return nil, errors.New("no query results found in response")
}
results := make([]QueryResult, 0, len(resp.Output))
for _, output := range resp.Output {
result := QueryResult{
Text: getStringValue(output.Fields, "query"),
Embedding: output.Vector,
Score: output.Score,
Answer: getStringValue(output.Fields, "answer"),
}
results = append(results, result)
}
return results, nil
}
type document struct {
Vector []float64 `json:"vector"`
Fields map[string]string `json:"fields"`
}
type insertRequest struct {
Docs []document `json:"docs"`
}
func (d *DvProvider) constructUploadParameters(emb []float64, queryString string, answer string) (string, []byte, [][2]string, error) {
url := "/v1/collections/" + d.config.collectionID + "/docs"
doc := document{
Vector: emb,
Fields: map[string]string{
"query": queryString,
"answer": answer,
},
}
requestBody, err := json.Marshal(insertRequest{Docs: []document{doc}})
if err != nil {
return "", nil, nil, err
}
header := [][2]string{
{"Content-Type", "application/json"},
{"dashvector-auth-token", d.config.apiKey},
}
return url, requestBody, header, err
}
func (d *DvProvider) UploadEmbedding(queryString string, queryEmb []float64, ctx wrapper.HttpContext, log wrapper.Log, callback func(ctx wrapper.HttpContext, log wrapper.Log, err error)) error {
url, body, headers, err := d.constructUploadParameters(queryEmb, queryString, "")
if err != nil {
return err
}
err = d.client.Post(
url,
headers,
body,
func(statusCode int, responseHeaders http.Header, responseBody []byte) {
log.Debugf("statusCode:%d, responseBody:%s", statusCode, string(responseBody))
if statusCode != http.StatusOK {
err = fmt.Errorf("failed to upload embedding: %d", statusCode)
}
callback(ctx, log, err)
},
d.config.timeout)
return err
}
func (d *DvProvider) UploadAnswerAndEmbedding(queryString string, queryEmb []float64, queryAnswer string, ctx wrapper.HttpContext, log wrapper.Log, callback func(ctx wrapper.HttpContext, log wrapper.Log, err error)) error {
url, body, headers, err := d.constructUploadParameters(queryEmb, queryString, queryAnswer)
if err != nil {
return err
}
err = d.client.Post(
url,
headers,
body,
func(statusCode int, responseHeaders http.Header, responseBody []byte) {
log.Debugf("statusCode:%d, responseBody:%s", statusCode, string(responseBody))
if statusCode != http.StatusOK {
err = fmt.Errorf("failed to upload embedding: %d", statusCode)
}
callback(ctx, log, err)
},
d.config.timeout)
return err
}

View File

@@ -0,0 +1,167 @@
package vector
import (
"errors"
"github.com/alibaba/higress/plugins/wasm-go/pkg/wrapper"
"github.com/tidwall/gjson"
)
const (
PROVIDER_TYPE_DASH_VECTOR = "dashvector"
PROVIDER_TYPE_CHROMA = "chroma"
)
type providerInitializer interface {
ValidateConfig(ProviderConfig) error
CreateProvider(ProviderConfig) (Provider, error)
}
var (
providerInitializers = map[string]providerInitializer{
PROVIDER_TYPE_DASH_VECTOR: &dashVectorProviderInitializer{},
// PROVIDER_TYPE_CHROMA: &chromaProviderInitializer{},
}
)
// QueryResult 定义通用的查询结果的结构体
type QueryResult struct {
Text string // 相似的文本
Embedding []float64 // 相似文本的向量
Score float64 // 文本的向量相似度或距离等度量
Answer string // 相似文本对应的LLM生成的回答
}
type Provider interface {
GetProviderType() string
}
type EmbeddingQuerier interface {
QueryEmbedding(
emb []float64,
ctx wrapper.HttpContext,
log wrapper.Log,
callback func(results []QueryResult, ctx wrapper.HttpContext, log wrapper.Log, err error)) error
}
type EmbeddingUploader interface {
UploadEmbedding(
queryString string,
queryEmb []float64,
ctx wrapper.HttpContext,
log wrapper.Log,
callback func(ctx wrapper.HttpContext, log wrapper.Log, err error)) error
}
type AnswerAndEmbeddingUploader interface {
UploadAnswerAndEmbedding(
queryString string,
queryEmb []float64,
answer string,
ctx wrapper.HttpContext,
log wrapper.Log,
callback func(ctx wrapper.HttpContext, log wrapper.Log, err error)) error
}
type StringQuerier interface {
QueryString(
queryString string,
ctx wrapper.HttpContext,
log wrapper.Log,
callback func(results []QueryResult, ctx wrapper.HttpContext, log wrapper.Log, err error)) error
}
type SimilarityThresholdProvider interface {
GetSimilarityThreshold() float64
}
type ProviderConfig struct {
// @Title zh-CN 向量存储服务提供者类型
// @Description zh-CN 向量存储服务提供者类型,例如 dashvector、chroma
typ string
// @Title zh-CN 向量存储服务名称
// @Description zh-CN 向量存储服务名称
serviceName string
// @Title zh-CN 向量存储服务域名
// @Description zh-CN 向量存储服务域名
serviceHost string
// @Title zh-CN 向量存储服务端口
// @Description zh-CN 向量存储服务端口
servicePort int64
// @Title zh-CN 向量存储服务 API Key
// @Description zh-CN 向量存储服务 API Key
apiKey string
// @Title zh-CN 返回TopK结果
// @Description zh-CN 返回TopK结果默认为 1
topK int
// @Title zh-CN 请求超时
// @Description zh-CN 请求向量存储服务的超时时间单位为毫秒。默认值是10000即10秒
timeout uint32
// @Title zh-CN DashVector 向量存储服务 Collection ID
// @Description zh-CN DashVector 向量存储服务 Collection ID
collectionID string
// @Title zh-CN 相似度度量阈值
// @Description zh-CN 默认相似度度量阈值,默认为 1000。
Threshold float64
// @Title zh-CN 相似度度量比较方式
// @Description zh-CN 相似度度量比较方式,默认为小于。
// 相似度度量方式有 Cosine, DotProduct, Euclidean 等,前两者值越大相似度越高,后者值越小相似度越高。
// 所以需要允许自定义比较方式,对于 Cosine 和 DotProduct 选择 gt对于 Euclidean 则选择 lt。
// 默认为 lt所有条件包括 lt (less than小于)、lte (less than or equal to小等于)、gt (greater than大于)、gte (greater than or equal to大等于)
ThresholdRelation string
}
func (c *ProviderConfig) GetProviderType() string {
return c.typ
}
func (c *ProviderConfig) FromJson(json gjson.Result) {
c.typ = json.Get("type").String()
// DashVector
c.serviceName = json.Get("serviceName").String()
c.serviceHost = json.Get("serviceHost").String()
c.servicePort = int64(json.Get("servicePort").Int())
if c.servicePort == 0 {
c.servicePort = 443
}
c.apiKey = json.Get("apiKey").String()
c.collectionID = json.Get("collectionID").String()
c.topK = int(json.Get("topK").Int())
if c.topK == 0 {
c.topK = 1
}
c.timeout = uint32(json.Get("timeout").Int())
if c.timeout == 0 {
c.timeout = 10000
}
c.Threshold = json.Get("threshold").Float()
if c.Threshold == 0 {
c.Threshold = 1000
}
c.ThresholdRelation = json.Get("thresholdRelation").String()
if c.ThresholdRelation == "" {
c.ThresholdRelation = "lt"
}
}
func (c *ProviderConfig) Validate() error {
if c.typ == "" {
return errors.New("vector database service is required")
}
initializer, has := providerInitializers[c.typ]
if !has {
return errors.New("unknown vector database service provider type: " + c.typ)
}
if err := initializer.ValidateConfig(*c); err != nil {
return err
}
return nil
}
func CreateProvider(pc ProviderConfig) (Provider, error) {
initializer, has := providerInitializers[pc.typ]
if !has {
return nil, errors.New("unknown provider type: " + pc.typ)
}
return initializer.CreateProvider(pc)
}

View File

@@ -0,0 +1,4 @@
.DEFAULT:
build:
tinygo build -o ai-proxy.wasm -scheduler=none -target=wasi -gc=custom -tags='custommalloc nottinygc_finalizer proxy_wasm_version_0_2_100' ./main.go
mv ai-proxy.wasm ../../../../docker-compose-test/

View File

@@ -669,6 +669,18 @@ provider:
timeout: 1200000
```
### 使用 original 协议代理 Coze 应用
**配置信息**
```yaml
provider:
type: coze
apiTokens:
- YOUR_COZE_API_KEY
protocol: original
```
### 使用月之暗面配合其原生的文件上下文
提前上传文件至月之暗面,以文件内容作为上下文使用其 AI 服务。

View File

@@ -656,6 +656,16 @@ providers:
timeout: 1200000
```
### Using original Protocol Proxy for Coze applications
```yaml
provider:
type: coze
apiTokens:
- YOUR_COZE_API_KEY
protocol: original
```
### Utilizing Moonshot with its Native File Context
Upload files to Moonshot in advance and use its AI services based on file content.

View File

@@ -10,7 +10,7 @@ require (
github.com/alibaba/higress/plugins/wasm-go v0.0.0
github.com/higress-group/proxy-wasm-go-sdk v0.0.0-20240711023527-ba358c48772f
github.com/stretchr/testify v1.8.4
github.com/tidwall/gjson v1.14.3
github.com/tidwall/gjson v1.17.3
)
require (

View File

@@ -13,8 +13,8 @@ github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZN
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/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=

View File

@@ -82,7 +82,8 @@ func onHttpRequestHeader(ctx wrapper.HttpContext, pluginConfig config.PluginConf
providerConfig := pluginConfig.GetProviderConfig()
if apiName == "" && !providerConfig.IsOriginal() {
log.Debugf("[onHttpRequestHeader] unsupported path: %s", path.Path)
_ = util.SendResponse(404, "ai-proxy.unknown_api", util.MimeTypeTextPlain, "API not found: "+path.Path)
// _ = util.SendResponse(404, "ai-proxy.unknown_api", util.MimeTypeTextPlain, "API not found: "+path.Path)
log.Debugf("[onHttpRequestHeader] no send response")
return types.ActionContinue
}
ctx.SetContext(ctxKeyApiName, apiName)

View File

@@ -0,0 +1,44 @@
package provider
import (
"errors"
"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/types"
)
const (
cozeDomain = "api.coze.cn"
)
type cozeProviderInitializer struct{}
func (m *cozeProviderInitializer) 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 *cozeProviderInitializer) CreateProvider(config ProviderConfig) (Provider, error) {
return &cozeProvider{
config: config,
contextCache: createContextCache(&config),
}, nil
}
type cozeProvider struct {
config ProviderConfig
contextCache *contextCache
}
func (m *cozeProvider) GetProviderType() string {
return providerTypeCoze
}
func (m *cozeProvider) OnRequestHeaders(ctx wrapper.HttpContext, apiName ApiName, log wrapper.Log) (types.Action, error) {
_ = util.OverwriteRequestHost(cozeDomain)
_ = util.OverwriteRequestAuthorization("Bearer " + m.config.GetRandomToken())
return types.ActionContinue, nil
}

View File

@@ -42,6 +42,7 @@ const (
providerTypeMistral = "mistral"
providerTypeCohere = "cohere"
providerTypeDoubao = "doubao"
providerTypeCoze = "coze"
protocolOpenAI = "openai"
protocolOriginal = "original"
@@ -101,6 +102,7 @@ var (
providerTypeMistral: &mistralProviderInitializer{},
providerTypeCohere: &cohereProviderInitializer{},
providerTypeDoubao: &doubaoProviderInitializer{},
providerTypeCoze: &cozeProviderInitializer{},
}
)

View File

@@ -81,7 +81,7 @@ func (config *AISecurityConfig) incrementCounter(metricName string, inc uint64)
func urlEncoding(rawStr string) string {
encodedStr := url.PathEscape(rawStr)
encodedStr = strings.ReplaceAll(encodedStr, "+", "%20")
encodedStr = strings.ReplaceAll(encodedStr, "+", "%2B")
encodedStr = strings.ReplaceAll(encodedStr, ":", "%3A")
encodedStr = strings.ReplaceAll(encodedStr, "=", "%3D")
encodedStr = strings.ReplaceAll(encodedStr, "&", "%26")
@@ -106,7 +106,7 @@ func getSign(params map[string]string, secret string) string {
})
canonicalStr := strings.Join(paramArray, "&")
signStr := "POST&%2F&" + urlEncoding(canonicalStr)
// proxywasm.LogInfo(signStr)
proxywasm.LogDebugf("String to sign is: %s", signStr)
return hmacSha1(signStr, secret)
}
@@ -187,19 +187,16 @@ func onHttpRequestHeaders(ctx wrapper.HttpContext, config AISecurityConfig, log
log.Debugf("request checking is disabled")
ctx.DontReadRequestBody()
}
if !config.checkResponse {
log.Debugf("response checking is disabled")
ctx.DontReadResponseBody()
}
return types.ActionContinue
}
func onHttpRequestBody(ctx wrapper.HttpContext, config AISecurityConfig, body []byte, log wrapper.Log) types.Action {
log.Debugf("checking request body...")
content := gjson.GetBytes(body, config.requestContentJsonPath).String()
content := gjson.GetBytes(body, config.requestContentJsonPath).Raw
model := gjson.GetBytes(body, "model").Raw
ctx.SetContext("requestModel", model)
if content != "" {
log.Debugf("Raw request content is: %s", content)
if len(content) > 0 {
timestamp := time.Now().UTC().Format("2006-01-02T15:04:05Z")
randomID, _ := generateHexID(16)
params := map[string]string{
@@ -212,7 +209,7 @@ func onHttpRequestBody(ctx wrapper.HttpContext, config AISecurityConfig, body []
"AccessKeyId": config.ak,
"Timestamp": timestamp,
"Service": config.requestCheckService,
"ServiceParameters": `{"content": "` + content + `"}`,
"ServiceParameters": fmt.Sprintf(`{"content": %s}`, content),
}
signature := getSign(params, config.sk+"&")
reqParams := url.Values{}
@@ -320,6 +317,11 @@ func reconvertHeaders(hs map[string][]string) [][2]string {
}
func onHttpResponseHeaders(ctx wrapper.HttpContext, config AISecurityConfig, log wrapper.Log) types.Action {
if !config.checkResponse {
log.Debugf("response checking is disabled")
ctx.DontReadResponseBody()
return types.ActionContinue
}
headers, err := proxywasm.GetHttpResponseHeaders()
if err != nil {
log.Warnf("failed to get response headers: %v", err)
@@ -339,7 +341,7 @@ func onHttpResponseBody(ctx wrapper.HttpContext, config AISecurityConfig, body [
if isStreamingResponse {
content = extractMessageFromStreamingBody(body, config.responseStreamContentJsonPath)
} else {
content = gjson.GetBytes(body, config.responseContentJsonPath).String()
content = gjson.GetBytes(body, config.responseContentJsonPath).Raw
}
log.Debugf("Raw response content is: %s", content)
if len(content) > 0 {
@@ -355,7 +357,7 @@ func onHttpResponseBody(ctx wrapper.HttpContext, config AISecurityConfig, body [
"AccessKeyId": config.ak,
"Timestamp": timestamp,
"Service": config.responseCheckService,
"ServiceParameters": `{"content": "` + content + `"}`,
"ServiceParameters": fmt.Sprintf(`{"content": %s}`, content),
}
signature := getSign(params, config.sk+"&")
reqParams := url.Values{}
@@ -398,12 +400,12 @@ func onHttpResponseBody(ctx wrapper.HttpContext, config AISecurityConfig, body [
var jsonData []byte
if config.protocolOriginal {
jsonData = []byte(denyMessage)
} else if strings.Contains(strings.Join(hdsMap["content-type"], ";"), "event-stream") {
} else if isStreamingResponse {
randomID := generateRandomID()
jsonData = []byte(fmt.Sprintf(OpenAIStreamResponseFormat, randomID, model, respAdvice.Array()[0].Get("Answer").String(), randomID, model))
jsonData = []byte(fmt.Sprintf(OpenAIStreamResponseFormat, randomID, model, denyMessage, randomID, model))
} else {
randomID := generateRandomID()
jsonData = []byte(fmt.Sprintf(OpenAIResponseFormat, randomID, model, respAdvice.Array()[0].Get("Answer").String()))
jsonData = []byte(fmt.Sprintf(OpenAIResponseFormat, randomID, model, denyMessage))
}
delete(hdsMap, "content-length")
hdsMap[":status"] = []string{fmt.Sprint(config.denyCode)}
@@ -432,10 +434,10 @@ func extractMessageFromStreamingBody(data []byte, jsonPath string) string {
strChunks := []string{}
for _, chunk := range chunks {
// Example: "choices":[{"index":0,"delta":{"role":"assistant","content":"%s"},"logprobs":null,"finish_reason":null}]
jsonObj := gjson.GetBytes(chunk, jsonPath)
if jsonObj.Exists() {
strChunks = append(strChunks, jsonObj.String())
jsonRaw := gjson.GetBytes(chunk, jsonPath).Raw
if len(jsonRaw) > 2 {
strChunks = append(strChunks, jsonRaw[1:len(jsonRaw)-1])
}
}
return strings.Join(strChunks, "")
return fmt.Sprintf(`"%s"`, strings.Join(strChunks, ""))
}

View File

@@ -17,8 +17,12 @@ description: 前端灰度插件配置参考
| 名称 | 数据类型 | 填写要求 | 默认值 | 描述 |
|----------------|--------------|----|-----|----------------------------------------------------------------------------------------------------|
| `grayKey` | string | 非必填 | - | 用户ID的唯一标识可以来自Cookie或者Header中比如 userid如果没有填写则使用`rules[].grayTagKey``rules[].grayTagValue`过滤灰度规则 |
| `localStorageGrayKey` | string | 非必填 | - | 使用JWT鉴权方式用户ID的唯一标识来自`localStorage`中,如果配置了当前参数,则`grayKey`失效 |
| `graySubKey` | string | 非必填 | - | 用户身份信息可能以JSON形式透出比如`userInfo:{ userCode:"001" }`,当前例子`graySubKey`取值为`userCode` |
| `userStickyMaxAge` | int | 非必填 | 172800 | 用户粘滞的时长:单位为秒,默认为`172800`2天时间 |
| `skippedPathPrefixes` | array of strings | 非必填 | - | 用于排除特定路径,避免当前插件处理这些请求。例如,在 rewrite 场景下XHR 接口请求 `/api/xxx` 如果经过插件转发逻辑,可能会导致非预期的结果。 |
| `skippedByHeaders` | map of string to string | 非必填 | - | 用于通过请求头过滤,指定哪些请求不被当前插件
处理。`skippedPathPrefixes` 的优先级高于当前配置且页面HTML请求不受本配置的影响。若本配置为空默认会判断`sec-fetch-mode=cors`以及`upgrade=websocket`两个header头进行过滤 |
| `rules` | array of object | 必填 | - | 用户定义不同的灰度规则,适配不同的灰度场景 |
| `rewrite` | object | 必填 | - | 重写配置一般用于OSS/CDN前端部署的重写配置 |
| `baseDeployment` | object | 非必填 | - | 配置Base基线规则的配置 |
@@ -53,15 +57,17 @@ description: 前端灰度插件配置参考
| 名称 | 数据类型 | 填写要求 | 默认值 | 描述 |
|----------------|--------------|------|-----|-----------------------------------------------------------------------------------|
| `version` | string | 必填 | - | Base版本的版本号作为兜底的版本 |
| `version` | string | 必填 | - | Base版本的版本号作为兜底的版本 |
| `versionPredicates` | string | 必填 | - | 和`version`含义相同,但是满足多版本的需求:根据不同路由映射不同的`Version`版本。一般用于微前端的场景:一个主应用需要管理多个微应用 |
`grayDeployments`字段配置说明:
| 名称 | 数据类型 | 填写要求 | 默认值 | 描述 |
|--------|--------|------|-----|-------------------------------------------------|
| `version` | string | 必填 | - | Gray版本的版本号如果命中灰度规则则使用此版本。如果是非CDN部署在header添加`x-higress-tag` |
| `version` | string | 必填 | - | Gray版本的版本号如果命中灰度规则则使用此版本。如果是非CDN部署在header添加`x-higress-tag` |
| `versionPredicates` | string | 必填 | - | 和`version`含义相同,但是满足多版本的需求:根据不同路由映射不同的`Version`版本。一般用于微前端的场景:一个主应用需要管理多个微应用 |
| `backendVersion` | string | 必填 | - | 后端灰度版本,配合`key``${backendGrayTag}`写入cookie中 |
| `name` | string | 必填 | - | 规则名称和`rules[].name`关联 |
| `name` | string | 必填 | - | 规则名称和`rules[].name`关联 |
| `enabled` | boolean | 必填 | - | 是否启动当前灰度规则 |
| `weight` | int | 非必填 | - | 按照比例灰度,比如`50`。注意灰度规则权重总和不能超过100如果同时配置了`grayKey`以及`grayDeployments[0].weight`按照比例灰度优先生效 |
> 为了实现按比例weight 进行灰度发布,并确保用户粘滞,我们需要确认客户端的唯一性。如果配置了 grayKey则将其用作唯一标识如果未配置 grayKey则使用客户端的访问 IP 地址作为唯一标识。
@@ -168,6 +174,30 @@ cookie存在`appInfo`的JSON数据其中包含`userId`字段为当前的唯
否则使用`version: base`版本
### 用户信息存储在LocalStorage
由于网关插件需要识别用户为唯一身份信息HTTP协议进行信息传输只能在Header中传递。如果用户信息存储在LocalStorage在首页注入一段脚本将LocalStorage中的用户信息设置到cookie中。
```
(function() {
var grayKey = '@@X_GRAY_KEY';
var cookies = document.cookie.split('; ').filter(function(row) {
return row.indexOf(grayKey + '=') === 0;
});
try {
if (typeof localStorage !== 'undefined' && localStorage !== null) {
var storageValue = localStorage.getItem(grayKey);
var cookieValue = cookies.length > 0 ? decodeURIComponent(cookies[0].split('=')[1]) : null;
if (storageValue && storageValue.indexOf('=') < 0 && cookieValue && cookieValue !== storageValue) {
document.cookie = grayKey + '=' + encodeURIComponent(storageValue) + '; path=/;';
window.location.reload();
}
}
} catch (error) {
// xx
}
})();
```
### rewrite重写配置
> 一般用于CDN部署场景
```yml
@@ -241,4 +271,4 @@ injection:
- <script>console.log('hello world after')</script>
- <script>console.log('hello world after2')</script>
```
通过 `injection`往HTML首页注入代码可以在`head`标签注入代码,也可以在`body`标签的`first``last`位置注入代码。
通过 `injection`往HTML首页注入代码可以在`head`标签注入代码,也可以在`body`标签的`first``last`位置注入代码。

View File

@@ -12,6 +12,7 @@ const (
XPreHigressTag = "x-pre-higress-tag"
IsPageRequest = "is-page-request"
IsNotFound = "is-not-found"
EnabledGray = "enabled-gray"
)
type LogInfo func(format string, args ...interface{})
@@ -24,11 +25,12 @@ type GrayRule struct {
}
type Deployment struct {
Name string
Enabled bool
Version string
BackendVersion string
Weight int
Name string
Enabled bool
Version string
BackendVersion string
Weight int
VersionPredicates map[string]string
}
type Rewrite struct {
@@ -49,17 +51,20 @@ type BodyInjection struct {
}
type GrayConfig struct {
UserStickyMaxAge string
TotalGrayWeight int
GrayKey string
GraySubKey string
Rules []*GrayRule
Rewrite *Rewrite
Html string
BaseDeployment *Deployment
GrayDeployments []*Deployment
BackendGrayTag string
Injection *Injection
UserStickyMaxAge string
TotalGrayWeight int
GrayKey string
LocalStorageGrayKey string
GraySubKey string
Rules []*GrayRule
Rewrite *Rewrite
Html string
BaseDeployment *Deployment
GrayDeployments []*Deployment
BackendGrayTag string
Injection *Injection
SkippedPathPrefixes []string
SkippedByHeaders map[string]string
}
func convertToStringList(results []gjson.Result) []string {
@@ -81,11 +86,17 @@ func convertToStringMap(result gjson.Result) map[string]string {
func JsonToGrayConfig(json gjson.Result, grayConfig *GrayConfig) {
// 解析 GrayKey
grayConfig.LocalStorageGrayKey = json.Get("localStorageGrayKey").String()
grayConfig.GrayKey = json.Get("grayKey").String()
if grayConfig.LocalStorageGrayKey != "" {
grayConfig.GrayKey = grayConfig.LocalStorageGrayKey
}
grayConfig.GraySubKey = json.Get("graySubKey").String()
grayConfig.BackendGrayTag = json.Get("backendGrayTag").String()
grayConfig.UserStickyMaxAge = json.Get("userStickyMaxAge").String()
grayConfig.Html = json.Get("html").String()
grayConfig.SkippedPathPrefixes = convertToStringList(json.Get("skippedPathPrefixes").Array())
grayConfig.SkippedByHeaders = convertToStringMap(json.Get("skippedByHeaders"))
if grayConfig.UserStickyMaxAge == "" {
// 默认值2天
@@ -119,8 +130,9 @@ func JsonToGrayConfig(json gjson.Result, grayConfig *GrayConfig) {
grayDeployments := json.Get("grayDeployments").Array()
grayConfig.BaseDeployment = &Deployment{
Name: baseDeployment.Get("name").String(),
Version: strings.Trim(baseDeployment.Get("version").String(), " "),
Name: baseDeployment.Get("name").String(),
Version: strings.Trim(baseDeployment.Get("version").String(), " "),
VersionPredicates: convertToStringMap(baseDeployment.Get("versionPredicates")),
}
for _, item := range grayDeployments {
if !item.Get("enabled").Bool() {
@@ -128,11 +140,12 @@ func JsonToGrayConfig(json gjson.Result, grayConfig *GrayConfig) {
}
grayWeight := int(item.Get("weight").Int())
grayConfig.GrayDeployments = append(grayConfig.GrayDeployments, &Deployment{
Name: item.Get("name").String(),
Enabled: item.Get("enabled").Bool(),
Version: strings.Trim(item.Get("version").String(), " "),
BackendVersion: item.Get("backendVersion").String(),
Weight: grayWeight,
Name: item.Get("name").String(),
Enabled: item.Get("enabled").Bool(),
Version: strings.Trim(item.Get("version").String(), " "),
BackendVersion: item.Get("backendVersion").String(),
Weight: grayWeight,
VersionPredicates: convertToStringMap(item.Get("versionPredicates")),
})
grayConfig.TotalGrayWeight += grayWeight
}

View File

@@ -73,23 +73,25 @@ static_resources:
],
"rewrite": {
"host": "frontend-gray-cn-shanghai.oss-cn-shanghai-internal.aliyuncs.com",
"notFoundUri": "/cygtapi/{version}/333.html",
"indexRouting": {
"/app1": "/cygtapi/{version}/index.html",
"/": "/cygtapi/{version}/index.html"
"/app1": "/mfe/app1/{version}/index.html",
"/": "/mfe/app1/{version}/index.html"
},
"fileRouting": {
"/": "/cygtapi/{version}",
"/app1": "/cygtapi/{version}"
"/": "/mfe/app1/{version}",
"/app1": "/mfe/app1/{version}"
}
},
"skippedPathPrefixes": [
"/api/"
],
"baseDeployment": {
"version": "base"
"version": "dev"
},
"grayDeployments": [
{
"name": "beta-user",
"version": "gray",
"version": "0.0.1",
"enabled": true
}
],
@@ -107,8 +109,7 @@ static_resources:
"<script>console.log('hello world after2')</script>"
]
}
},
"html": "<!DOCTYPE html>\n <html lang=\"zh-CN\">\n<head>\n<title>app1</title>\n<meta charset=\"utf-8\" />\n</head>\n<body>\n\t测试替换html版本\n\t<br />\n\t版本: {version}\n\t<br />\n\t<script src=\"./{version}/a.js\"></script>\n</body>\n</html>"
}
}
- name: envoy.filters.http.router
typed_config:

View File

@@ -3,6 +3,7 @@ package main
import (
"fmt"
"net/http"
"path"
"strings"
"github.com/alibaba/higress/plugins/wasm-go/extensions/frontend-gray/config"
@@ -21,27 +22,29 @@ func main() {
wrapper.ProcessRequestHeadersBy(onHttpRequestHeaders),
wrapper.ProcessResponseHeadersBy(onHttpResponseHeader),
wrapper.ProcessResponseBodyBy(onHttpResponseBody),
wrapper.ProcessStreamingResponseBodyBy(onStreamingResponseBody),
)
}
func parseConfig(json gjson.Result, grayConfig *config.GrayConfig, log wrapper.Log) error {
// 解析json 为GrayConfig
config.JsonToGrayConfig(json, grayConfig)
log.Infof("Rewrite: %v, GrayDeployments: %v", json.Get("rewrite"), json.Get("grayDeployments"))
log.Debugf("Rewrite: %v, GrayDeployments: %v", json.Get("rewrite"), json.Get("grayDeployments"))
return nil
}
func onHttpRequestHeaders(ctx wrapper.HttpContext, grayConfig config.GrayConfig, log wrapper.Log) types.Action {
if !util.IsGrayEnabled(grayConfig) {
requestPath, _ := proxywasm.GetHttpRequestHeader(":path")
requestPath = path.Clean(requestPath)
enabledGray := util.IsGrayEnabled(grayConfig, requestPath)
ctx.SetContext(config.EnabledGray, enabledGray)
if !enabledGray {
ctx.DontReadRequestBody()
return types.ActionContinue
}
cookies, _ := proxywasm.GetHttpRequestHeader("cookie")
path, _ := proxywasm.GetHttpRequestHeader(":path")
fetchMode, _ := proxywasm.GetHttpRequestHeader("sec-fetch-mode")
isPageRequest := util.IsPageRequest(fetchMode, path)
isPageRequest := util.IsPageRequest(requestPath)
hasRewrite := len(grayConfig.Rewrite.File) > 0 || len(grayConfig.Rewrite.Index) > 0
grayKeyValueByCookie := util.ExtractCookieValueByKey(cookies, grayConfig.GrayKey)
grayKeyValueByHeader, _ := proxywasm.GetHttpRequestHeader(grayConfig.GrayKey)
@@ -67,22 +70,28 @@ func onHttpRequestHeaders(ctx wrapper.HttpContext, grayConfig config.GrayConfig,
}
// 如果没有配置比例,则进行灰度规则匹配
if isPageRequest {
if grayConfig.TotalGrayWeight > 0 {
log.Infof("grayConfig.TotalGrayWeight: %v", grayConfig.TotalGrayWeight)
deployment = util.FilterGrayWeight(&grayConfig, preVersion, preUniqueClientId, uniqueClientId)
} else {
deployment = util.FilterGrayRule(&grayConfig, grayKeyValue)
}
log.Infof("index deployment: %v, path: %v, backend: %v, xPreHigressVersion: %s,%s", deployment, path, deployment.BackendVersion, preVersion, preUniqueClientId)
if util.IsSupportMultiVersion(grayConfig) {
deployment = util.FilterMultiVersionGrayRule(&grayConfig, grayKeyValue, requestPath)
log.Infof("multi version %v", deployment)
} else {
grayDeployment := util.FilterGrayRule(&grayConfig, grayKeyValue)
deployment = util.GetVersion(grayConfig, grayDeployment, preVersion, isPageRequest)
if isPageRequest {
if grayConfig.TotalGrayWeight > 0 {
log.Infof("grayConfig.TotalGrayWeight: %v", grayConfig.TotalGrayWeight)
deployment = util.FilterGrayWeight(&grayConfig, preVersion, preUniqueClientId, uniqueClientId)
} else {
deployment = util.FilterGrayRule(&grayConfig, grayKeyValue)
}
log.Infof("index deployment: %v, path: %v, backend: %v, xPreHigressVersion: %s,%s", deployment, requestPath, deployment.BackendVersion, preVersion, preUniqueClientId)
} else {
grayDeployment := util.FilterGrayRule(&grayConfig, grayKeyValue)
deployment = util.GetVersion(grayConfig, grayDeployment, preVersion, isPageRequest)
}
ctx.SetContext(config.XPreHigressTag, deployment.Version)
ctx.SetContext(grayConfig.BackendGrayTag, deployment.BackendVersion)
}
proxywasm.AddHttpRequestHeader(config.XHigressTag, deployment.Version)
ctx.SetContext(config.XPreHigressTag, deployment.Version)
ctx.SetContext(grayConfig.BackendGrayTag, deployment.BackendVersion)
ctx.SetContext(config.IsPageRequest, isPageRequest)
ctx.SetContext(config.XUniqueClientId, uniqueClientId)
@@ -92,21 +101,24 @@ func onHttpRequestHeaders(ctx wrapper.HttpContext, grayConfig config.GrayConfig,
}
if hasRewrite {
rewritePath := path
rewritePath := requestPath
if isPageRequest {
rewritePath = util.IndexRewrite(path, deployment.Version, grayConfig.Rewrite.Index)
rewritePath = util.IndexRewrite(requestPath, deployment.Version, grayConfig.Rewrite.Index)
} else {
rewritePath = util.PrefixFileRewrite(path, deployment.Version, grayConfig.Rewrite.File)
rewritePath = util.PrefixFileRewrite(requestPath, deployment.Version, grayConfig.Rewrite.File)
}
if requestPath != rewritePath {
log.Infof("rewrite path:%s, rewritePath:%s, Version:%v", requestPath, rewritePath, deployment.Version)
proxywasm.ReplaceHttpRequestHeader(":path", rewritePath)
}
log.Infof("rewrite path: %s %s %v", path, deployment.Version, rewritePath)
proxywasm.ReplaceHttpRequestHeader(":path", rewritePath)
}
return types.ActionContinue
}
func onHttpResponseHeader(ctx wrapper.HttpContext, grayConfig config.GrayConfig, log wrapper.Log) types.Action {
if !util.IsGrayEnabled(grayConfig) {
enabledGray, _ := ctx.GetContext(config.EnabledGray).(bool)
if !enabledGray {
ctx.DontReadResponseBody()
return types.ActionContinue
}
isPageRequest, ok := ctx.GetContext(config.IsPageRequest).(bool)
@@ -117,6 +129,9 @@ func onHttpResponseHeader(ctx wrapper.HttpContext, grayConfig config.GrayConfig,
if !isPageRequest {
ctx.DontReadResponseBody()
return types.ActionContinue
} else {
// 不会进去Streaming 的Body处理
ctx.BufferResponseBody()
}
status, err := proxywasm.GetHttpResponseHeader(":status")
@@ -158,33 +173,40 @@ func onHttpResponseHeader(ctx wrapper.HttpContext, grayConfig config.GrayConfig,
log.Errorf("error status: %s, error message: %v", status, err)
return types.ActionContinue
}
cacheControl, _ := proxywasm.GetHttpResponseHeader("cache-control")
if !strings.Contains(cacheControl, "no-cache") {
proxywasm.ReplaceHttpResponseHeader("cache-control", "no-cache, no-store, max-age=0, must-revalidate")
}
// 不会进去Streaming 的Body处理
ctx.BufferResponseBody()
proxywasm.ReplaceHttpResponseHeader("Cache-Control", "no-cache, no-store, max-age=0, must-revalidate")
frontendVersion := ctx.GetContext(config.XPreHigressTag).(string)
xUniqueClient := ctx.GetContext(config.XUniqueClientId).(string)
frontendVersion, isFeVersionOk := ctx.GetContext(config.XPreHigressTag).(string)
xUniqueClient, isUniqClientOk := ctx.GetContext(config.XUniqueClientId).(string)
// 设置前端的版本
proxywasm.AddHttpResponseHeader("Set-Cookie", fmt.Sprintf("%s=%s,%s; Max-Age=%s; Path=/;", config.XPreHigressTag, frontendVersion, xUniqueClient, grayConfig.UserStickyMaxAge))
if isFeVersionOk && isUniqClientOk && frontendVersion != "" {
proxywasm.AddHttpResponseHeader("Set-Cookie", fmt.Sprintf("%s=%s,%s; Max-Age=%s; Path=/;", config.XPreHigressTag, frontendVersion, xUniqueClient, grayConfig.UserStickyMaxAge))
}
// 设置后端的版本
if util.IsBackendGrayEnabled(grayConfig) {
backendVersion := ctx.GetContext(grayConfig.BackendGrayTag).(string)
proxywasm.AddHttpResponseHeader("Set-Cookie", fmt.Sprintf("%s=%s; Max-Age=%s; Path=/;", grayConfig.BackendGrayTag, backendVersion, grayConfig.UserStickyMaxAge))
backendVersion, isBackVersionOk := ctx.GetContext(grayConfig.BackendGrayTag).(string)
if isBackVersionOk && backendVersion != "" {
proxywasm.AddHttpResponseHeader("Set-Cookie", fmt.Sprintf("%s=%s; Max-Age=%s; Path=/;", grayConfig.BackendGrayTag, backendVersion, grayConfig.UserStickyMaxAge))
}
}
return types.ActionContinue
}
func onHttpResponseBody(ctx wrapper.HttpContext, grayConfig config.GrayConfig, body []byte, log wrapper.Log) types.Action {
if !util.IsGrayEnabled(grayConfig) {
enabledGray, _ := ctx.GetContext(config.EnabledGray).(bool)
if !enabledGray {
return types.ActionContinue
}
isPageRequest, ok := ctx.GetContext(config.IsPageRequest).(bool)
if !ok {
isPageRequest = false // 默认值
isPageRequest, isPageRequestOk := ctx.GetContext(config.IsPageRequest).(bool)
frontendVersion, isFeVersionOk := ctx.GetContext(config.XPreHigressTag).(string)
// 只处理首页相关请求
if !isFeVersionOk || !isPageRequestOk || !isPageRequest {
return types.ActionContinue
}
frontendVersion := ctx.GetContext(config.XPreHigressTag).(string)
isNotFound, ok := ctx.GetContext(config.IsNotFound).(bool)
if !ok {
isNotFound = false // 默认值
@@ -212,7 +234,8 @@ func onHttpResponseBody(ctx wrapper.HttpContext, grayConfig config.GrayConfig, b
return types.ActionContinue
}
if isPageRequest && isNotFound && grayConfig.Rewrite.Host != "" && grayConfig.Rewrite.NotFound != "" {
// 针对404页面处理
if isNotFound && grayConfig.Rewrite.Host != "" && grayConfig.Rewrite.NotFound != "" {
client := wrapper.NewClusterClient(wrapper.RouteCluster{Host: grayConfig.Rewrite.Host})
client.Get(strings.Replace(grayConfig.Rewrite.NotFound, "{version}", frontendVersion, -1), nil, func(statusCode int, responseHeaders http.Header, responseBody []byte) {
@@ -222,20 +245,18 @@ func onHttpResponseBody(ctx wrapper.HttpContext, grayConfig config.GrayConfig, b
return types.ActionPause
}
if isPageRequest {
// 将原始字节转换为字符串
newBody := string(body)
newBody = util.InjectContent(newBody, grayConfig.Injection)
if err := proxywasm.ReplaceHttpResponseBody([]byte(newBody)); err != nil {
return types.ActionContinue
}
// 处理响应体HTML
newBody := string(body)
newBody = util.InjectContent(newBody, grayConfig.Injection)
if grayConfig.LocalStorageGrayKey != "" {
localStr := strings.ReplaceAll(`<script>
!function(){var o,e,n="@@X_GRAY_KEY",t=document.cookie.split("; ").filter(function(o){return 0===o.indexOf(n+"=")});try{"undefined"!=typeof localStorage&&null!==localStorage&&(o=localStorage.getItem(n),e=0<t.length?decodeURIComponent(t[0].split("=")[1]):null,o)&&o.indexOf("=")<0&&e&&e!==o&&(document.cookie=n+"="+encodeURIComponent(o)+"; path=/;",window.location.reload())}catch(o){}}();
</script>
`, "@@X_GRAY_KEY", grayConfig.LocalStorageGrayKey)
newBody = strings.ReplaceAll(newBody, "<body>", "<body>\n"+localStr)
}
if err := proxywasm.ReplaceHttpResponseBody([]byte(newBody)); 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

@@ -47,7 +47,39 @@ func GetRealIpFromXff(xff string) string {
return ""
}
func IsGrayEnabled(grayConfig config.GrayConfig) bool {
func IsRequestSkippedByHeaders(grayConfig config.GrayConfig) bool {
secFetchMode, _ := proxywasm.GetHttpRequestHeader("sec-fetch-mode")
upgrade, _ := proxywasm.GetHttpRequestHeader("upgrade")
if len(grayConfig.SkippedByHeaders) == 0 {
// 默认不走插件逻辑的header
return secFetchMode == "cors" || upgrade == "websocket"
}
for headerKey, headerValue := range grayConfig.SkippedByHeaders {
requestHeader, _ := proxywasm.GetHttpRequestHeader(headerKey)
if requestHeader == headerValue {
return true
}
}
return false
}
func IsGrayEnabled(grayConfig config.GrayConfig, requestPath string) bool {
// 当前路径中前缀为 SkipedRoute则不走插件逻辑
for _, prefix := range grayConfig.SkippedPathPrefixes {
if strings.HasPrefix(requestPath, prefix) {
return false
}
}
// 如果是首页,进入插件逻辑
if IsPageRequest(requestPath) {
return true
}
// 检查header标识判断是否需要跳过
if IsRequestSkippedByHeaders(grayConfig) {
return false
}
// 检查是否存在重写主机
if grayConfig.Rewrite != nil && grayConfig.Rewrite.Host != "" {
return true
@@ -132,18 +164,38 @@ var indexSuffixes = []string{
".html", ".htm", ".jsp", ".php", ".asp", ".aspx", ".erb", ".ejs", ".twig",
}
func IsPageRequest(fetchMode string, myPath string) bool {
if fetchMode == "cors" {
return false
func IsPageRequest(requestPath string) bool {
if requestPath == "/" || requestPath == "" {
return true
}
ext := path.Ext(myPath)
ext := path.Ext(requestPath)
return ext == "" || ContainsValue(indexSuffixes, ext)
}
// SortKeysByLengthAndLexicographically 按长度降序和字典序排序键
func SortKeysByLengthAndLexicographically(matchRules map[string]string) []string {
keys := make([]string, 0, len(matchRules))
for prefix := range matchRules {
keys = append(keys, prefix)
}
sort.Slice(keys, func(i, j int) bool {
if len(keys[i]) != len(keys[j]) {
return len(keys[i]) > len(keys[j]) // 按长度排序
}
return keys[i] < keys[j] // 按字典序排序
})
return keys
}
// 首页Rewrite
func IndexRewrite(path, version string, matchRules map[string]string) string {
for prefix, rewrite := range matchRules {
// 使用新的排序函数
keys := SortKeysByLengthAndLexicographically(matchRules)
// 遍历排序后的键以找到最长匹配
for _, prefix := range keys {
if strings.HasPrefix(path, prefix) {
rewrite := matchRules[prefix]
newPath := strings.Replace(rewrite, "{version}", version, -1)
return newPath
}
@@ -152,18 +204,21 @@ func IndexRewrite(path, version string, matchRules map[string]string) string {
}
func PrefixFileRewrite(path, version string, matchRules map[string]string) string {
var matchedPrefix, replacement string
for prefix, template := range matchRules {
// 对规则的键进行排序
sortedKeys := SortKeysByLengthAndLexicographically(matchRules)
// 遍历排序后的键
for _, prefix := range sortedKeys {
if strings.HasPrefix(path, prefix) {
if len(prefix) > len(matchedPrefix) { // 找到更长的前缀
matchedPrefix = prefix
replacement = strings.Replace(template, "{version}", version, 1)
}
// 找到第一个匹配的前缀就停止,因为它是最长的匹配
replacement := strings.Replace(matchRules[prefix], "{version}", version, 1)
newPath := strings.Replace(path, prefix, replacement+"/", 1)
return filepath.Clean(newPath)
}
}
// 将path 中的前缀部分用 replacement 替换掉
newPath := strings.Replace(path, matchedPrefix, replacement+"/", 1)
return filepath.Clean(newPath)
// 如果没有匹配,返回原始路径
return path
}
func GetVersion(grayConfig config.GrayConfig, deployment *config.Deployment, xPreHigressVersion string, isPageRequest bool) *config.Deployment {
@@ -221,6 +276,43 @@ func GetGrayKey(grayKeyValueByCookie string, grayKeyValueByHeader string, graySu
return grayKeyValue
}
// 如果基础部署或任何灰度部署中包含VersionPredicates则认为是多版本配置
func IsSupportMultiVersion(grayConfig config.GrayConfig) bool {
if len(grayConfig.BaseDeployment.VersionPredicates) > 0 {
return true
}
for _, deployment := range grayConfig.GrayDeployments {
if len(deployment.VersionPredicates) > 0 {
return true
}
}
return false
}
// FilterMultiVersionGrayRule 过滤多版本灰度规则
func FilterMultiVersionGrayRule(grayConfig *config.GrayConfig, grayKeyValue string, requestPath string) *config.Deployment {
// 首先根据灰度键值获取当前部署
currentDeployment := FilterGrayRule(grayConfig, grayKeyValue)
// 创建一个新的部署对象,初始化版本为当前部署的版本
deployment := &config.Deployment{
Version: currentDeployment.Version,
}
// 对版本谓词的键进行排序
keys := SortKeysByLengthAndLexicographically(currentDeployment.VersionPredicates)
// 遍历排序后的键
for _, prefix := range keys {
// 如果请求路径以当前前缀开头
if strings.HasPrefix(requestPath, prefix) {
deployment.Version = currentDeployment.VersionPredicates[prefix]
return deployment
}
}
return deployment
}
// FilterGrayRule 过滤灰度规则
func FilterGrayRule(grayConfig *config.GrayConfig, grayKeyValue string) *config.Deployment {
for _, deployment := range grayConfig.GrayDeployments {

View File

@@ -53,6 +53,30 @@ func TestIndexRewrite(t *testing.T) {
}
}
func TestIndexRewrite2(t *testing.T) {
matchRules := map[string]string{
"/": "/{version}/index.html",
"/sta": "/sta/{version}/index.html",
"/static": "/static/{version}/index.html",
}
var tests = []struct {
path, output string
}{
{"/static123", "/static/v1.0.0/index.html"},
{"/static", "/static/v1.0.0/index.html"},
{"/sta", "/sta/v1.0.0/index.html"},
{"/", "/v1.0.0/index.html"},
}
for _, test := range tests {
testName := test.path
t.Run(testName, func(t *testing.T) {
output := IndexRewrite(testName, "v1.0.0", matchRules)
assert.Equal(t, test.output, output)
})
}
}
func TestPrefixFileRewrite(t *testing.T) {
matchRules := map[string]string{
// 前缀匹配
@@ -84,22 +108,20 @@ func TestPrefixFileRewrite(t *testing.T) {
func TestIsPageRequest(t *testing.T) {
var tests = []struct {
fetchMode string
p string
output bool
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},
{"/js/a.js", false},
{"/js/a.js", false},
{"/images/a.png", false},
{"/index", true},
{"/index.html", true},
{"/demo.php", true},
}
for _, test := range tests {
testPath := test.p
t.Run(testPath, func(t *testing.T) {
output := IsPageRequest(test.fetchMode, testPath)
output := IsPageRequest(testPath)
assert.Equal(t, test.output, output)
})
}

View File

@@ -60,7 +60,7 @@ func parseConfig(json gjson.Result, config *RestrictionConfig, log wrapper.Log)
}
status := json.Get("status")
if status.Exists() && status.Uint() > 1 {
config.Status = uint32(header.Uint())
config.Status = uint32(status.Uint())
} else {
config.Status = DefaultDenyStatus
}

View File

@@ -0,0 +1,2 @@
FROM scratch
COPY main.wasm plugin.wasm

View File

@@ -0,0 +1,4 @@
.DEFAULT:
build:
tinygo build -o main.wasm -scheduler=none -target=wasi -gc=custom -tags='custommalloc nottinygc_finalizer' ./main.go
mv main.wasm ../../../../docker-compose-test/

View File

@@ -177,7 +177,9 @@ func onHttpRequestHeaders(ctx wrapper.HttpContext, config RequestBlockConfig, lo
}
func onHttpRequestBody(ctx wrapper.HttpContext, config RequestBlockConfig, body []byte, log wrapper.Log) types.Action {
log.Infof("My request-block body: %s\n", string(body))
bodyStr := string(body)
if !config.caseSensitive {
bodyStr = strings.ToLower(bodyStr)
}

View File

@@ -26,10 +26,6 @@ import (
)
func onContentRequestHeaders(conditionGroups []ConditionGroup, log wrapper.Log) bool {
if len(conditionGroups) == 0 {
return false
}
for _, cg := range conditionGroups {
if matchCondition(&cg, log) {
addTagHeader(cg.HeaderName, cg.HeaderValue, log)

View File

@@ -16,6 +16,7 @@ package main
import (
"math/rand"
"strings"
"github.com/alibaba/higress/plugins/wasm-go/pkg/wrapper"
"github.com/higress-group/proxy-wasm-go-sdk/proxywasm/types"
@@ -60,7 +61,6 @@ type TrafficTagConfig struct {
WeightGroups []WeightGroup `json:"weightGroups,omitempty"`
DefaultTagKey string `json:"defaultTagKey,omitempty"`
DefaultTagVal string `json:"defaultTagVal,omitempty"`
randGen *rand.Rand
}
type ConditionGroup struct {
@@ -93,8 +93,11 @@ func main() {
}
func parseConfig(json gjson.Result, config *TrafficTagConfig, log wrapper.Log) error {
if err := jsonValidate(json, log); err != nil {
return err
jsonStr := strings.TrimSpace(json.Raw)
if jsonStr == "{}" || jsonStr == "" {
log.Error("plugin config is empty")
return nil
}
err := parseContentConfig(json, config, log)
@@ -106,7 +109,17 @@ func parseConfig(json gjson.Result, config *TrafficTagConfig, log wrapper.Log) e
}
func onHttpRequestHeaders(ctx wrapper.HttpContext, config TrafficTagConfig, log wrapper.Log) types.Action {
if add := (onContentRequestHeaders(config.ConditionGroups, log) || onWeightRequestHeaders(config.WeightGroups, config.randGen, log)); !add {
add := false
if len(config.ConditionGroups) != 0 {
add = add || onContentRequestHeaders(config.ConditionGroups, log)
}
if !add && len(config.WeightGroups) != 0 {
add = add || onWeightRequestHeaders(config.WeightGroups, rand.Uint64(), log)
}
if !add {
setDefaultTag(config.DefaultTagKey, config.DefaultTagVal, log)
}

View File

@@ -17,10 +17,8 @@ package main
import (
"errors"
"fmt"
"math/rand"
"strconv"
"strings"
"time"
"github.com/higress-group/proxy-wasm-go-sdk/proxywasm"
regexp "github.com/wasilibs/go-re2"
@@ -85,7 +83,6 @@ func parseWeightConfig(json gjson.Result, config *TrafficTagConfig, log wrapper.
var parseError error
var accumulatedWeight int64
config.WeightGroups = []WeightGroup{}
config.randGen = rand.New(rand.NewSource(time.Now().UnixNano()))
// parse default tag key and value
if k, v := json.Get(DefaultTagKey), json.Get(DefaultTagVal); k.Exists() && v.Exists() {

View File

@@ -15,14 +15,12 @@
package main
import (
"errors"
"fmt"
"net/url"
"strings"
"github.com/alibaba/higress/plugins/wasm-go/pkg/wrapper"
"github.com/higress-group/proxy-wasm-go-sdk/proxywasm"
"github.com/tidwall/gjson"
)
func setDefaultTag(k string, v string, log wrapper.Log) {
@@ -75,23 +73,3 @@ func addTagHeader(key string, value string, log wrapper.Log) {
}
log.Infof("ADD HEADER: %s, value: %s", key, value)
}
func jsonValidate(json gjson.Result, log wrapper.Log) error {
if !json.Exists() {
log.Error("plugin config is missing in JSON")
return errors.New("plugin config is missing in JSON")
}
jsonStr := strings.TrimSpace(json.Raw)
if jsonStr == "{}" || jsonStr == "" {
log.Error("plugin config is empty")
return errors.New("plugin config is empty")
}
if !gjson.Valid(json.Raw) {
log.Error("plugin config is invalid JSON")
return errors.New("plugin config is invalid JSON")
}
return nil
}

View File

@@ -15,16 +15,11 @@
package main
import (
"math/rand"
"github.com/alibaba/higress/plugins/wasm-go/pkg/wrapper"
)
func onWeightRequestHeaders(weightGroups []WeightGroup, randGen *rand.Rand, log wrapper.Log) bool {
if len(weightGroups) == 0 {
return false
}
randomValue := randGen.Uint64() % TotalWeight
func onWeightRequestHeaders(weightGroups []WeightGroup, randomNum uint64, log wrapper.Log) bool {
randomValue := randomNum % TotalWeight
log.Debugf("random value for weighted headers : %d", randomValue)
// CDF
for _, wg := range weightGroups {

View File

@@ -4,15 +4,15 @@ go 1.19
require (
github.com/google/uuid v1.3.0
github.com/higress-group/nottinygc v0.0.0-20231101025119-e93c4c2f8520
github.com/higress-group/proxy-wasm-go-sdk v0.0.0-20240711023527-ba358c48772f
github.com/higress-group/proxy-wasm-go-sdk v1.0.0
github.com/stretchr/testify v1.8.4
github.com/tidwall/gjson v1.14.3
github.com/tidwall/gjson v1.17.3
github.com/tidwall/resp v0.1.1
)
require (
github.com/davecgh/go-spew v1.1.1 // indirect
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

View File

@@ -4,16 +4,16 @@ 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/higress-group/proxy-wasm-go-sdk v1.0.0 h1:BZRNf4R7jr9hwRivg/E29nkVaKEak5MWjBDhWjuHijU=
github.com/higress-group/proxy-wasm-go-sdk v1.0.0/go.mod h1:iiSyFbo+rAtbtGt/bsefv8GU57h9CCLYGJA74/tF5/0=
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/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
github.com/tidwall/gjson v1.14.3 h1:9jvXn7olKEHU1S9vwoMGliaT8jq1vJ7IH/n9zD9Dnlw=
github.com/tidwall/gjson v1.14.3/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk=
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=

View File

@@ -36,7 +36,12 @@ type Log struct {
}
func (l Log) log(level LogLevel, msg string) {
msg = fmt.Sprintf("[%s] %s", l.pluginName, msg)
requestIDRaw, _ := proxywasm.GetProperty([]string{"x_request_id"})
requestID := string(requestIDRaw)
if requestID == "" {
requestID = "nil"
}
msg = fmt.Sprintf("[%s] [%s] %s", l.pluginName, requestID, msg)
switch level {
case LogLevelTrace:
proxywasm.LogTrace(msg)
@@ -54,7 +59,12 @@ func (l Log) log(level LogLevel, msg string) {
}
func (l Log) logFormat(level LogLevel, format string, args ...interface{}) {
format = fmt.Sprintf("[%s] %s", l.pluginName, format)
requestIDRaw, _ := proxywasm.GetProperty([]string{"x_request_id"})
requestID := string(requestIDRaw)
if requestID == "" {
requestID = "nil"
}
format = fmt.Sprintf("[%s] [%s] %s", l.pluginName, requestID, format)
switch level {
case LogLevelTrace:
proxywasm.LogTracef(format, args...)

View File

@@ -24,14 +24,8 @@ import (
"github.com/tidwall/gjson"
"github.com/alibaba/higress/plugins/wasm-go/pkg/matcher"
_ "github.com/higress-group/nottinygc"
)
//export sched_yield
func sched_yield() int32 {
return 0
}
type HttpContext interface {
Scheme() string
Host() string
@@ -370,6 +364,8 @@ func (ctx *CommonHttpCtx[PluginConfig]) SetResponseBodyBufferLimit(size uint32)
}
func (ctx *CommonHttpCtx[PluginConfig]) OnHttpRequestHeaders(numHeaders int, endOfStream bool) types.Action {
requestID, _ := proxywasm.GetHttpRequestHeader("x-request-id")
_ = proxywasm.SetProperty([]string{"x_request_id"}, []byte(requestID))
config, err := ctx.plugin.GetMatchConfig()
if err != nil {
ctx.plugin.vm.log.Errorf("get match config failed, err:%v", err)

View File

@@ -235,10 +235,11 @@ func (c RedisClusterClient[C]) Set(key string, value interface{}, callback Redis
func (c RedisClusterClient[C]) SetEx(key string, value interface{}, ttl int, callback RedisResponseCallback) error {
args := make([]interface{}, 0)
args = append(args, "setex")
args = append(args, "set")
args = append(args, key)
args = append(args, ttl)
args = append(args, value)
args = append(args, "ex")
args = append(args, ttl)
return RedisCall(c.cluster, respString(args), callback)
}

View File

@@ -21,10 +21,22 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5c6cb57a04249c6480766f7f7cef5467412af1490f8d1e243141daddada3264f"
[[package]]
name = "bytes"
version = "1.7.2"
name = "arc-swap"
version = "1.7.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "428d9aa8fbc0670b7b8d6030a7fadd0f86151cae55e4dbbece15f3780a3dfaf3"
checksum = "69f7f8c3906b62b754cd5326047894316021dcfe5a194c8ea52bdd94934a3457"
[[package]]
name = "autocfg"
version = "1.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ace50bade8e6234aa140d9a2f552bbee1db4d353f69b8217bc503490fc1a9f26"
[[package]]
name = "bytes"
version = "1.8.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9ac0150caa2ae65ca5bd83f25c7de183dea78d4d366469f148435e2acfbad0da"
[[package]]
name = "cfg-if"
@@ -32,12 +44,37 @@ version = "1.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "baf1de4339761588bc0619e3cbc0120ee582ebb74b53b4efbf79117bd2da40fd"
[[package]]
name = "combine"
version = "4.6.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ba5a308b75df32fe02788e748662718f03fde005016435c444eea572398219fd"
dependencies = [
"bytes",
"memchr",
]
[[package]]
name = "downcast-rs"
version = "1.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "75b325c5dbd37f80359721ad39aca5a29fb04c89279657cffdda8736d0c0b9d2"
[[package]]
name = "fnv"
version = "1.0.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1"
[[package]]
name = "form_urlencoded"
version = "1.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e13624c2627564efccf4934284bdd98cbaa14e79b0b5a141218e507b3a823456"
dependencies = [
"percent-encoding",
]
[[package]]
name = "getrandom"
version = "0.2.15"
@@ -63,10 +100,12 @@ dependencies = [
name = "higress-wasm-rust"
version = "0.1.0"
dependencies = [
"downcast-rs",
"http",
"lazy_static",
"multimap",
"proxy-wasm",
"redis",
"serde",
"serde_json",
"uuid",
@@ -83,6 +122,16 @@ dependencies = [
"itoa",
]
[[package]]
name = "idna"
version = "0.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "634d9b1461af396cad843f47fdba5597a4f9e6ddd4bfb6ff5d85028c25cb12f6"
dependencies = [
"unicode-bidi",
"unicode-normalization",
]
[[package]]
name = "itoa"
version = "1.0.11"
@@ -97,9 +146,9 @@ checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe"
[[package]]
name = "libc"
version = "0.2.155"
version = "0.2.161"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "97b3888a4aecf77e811145cadf6eef5901f4782c53886191b2f693f24761847c"
checksum = "8e9489c2807c139ffd9c1794f4af0ebe86a828db53ecdc7fea2111d0fed085d1"
[[package]]
name = "log"
@@ -123,16 +172,50 @@ dependencies = [
]
[[package]]
name = "once_cell"
version = "1.19.0"
name = "num-bigint"
version = "0.4.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3fdb12b2476b595f9358c5161aa467c2438859caa136dec86c26fdd2efe17b92"
checksum = "a5e44f723f1133c9deac646763579fdb3ac745e418f2a7af9cd0c431da1f20b9"
dependencies = [
"num-integer",
"num-traits",
]
[[package]]
name = "num-integer"
version = "0.1.46"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7969661fd2958a5cb096e56c8e1ad0444ac2bbcd0061bd28660485a44879858f"
dependencies = [
"num-traits",
]
[[package]]
name = "num-traits"
version = "0.2.19"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841"
dependencies = [
"autocfg",
]
[[package]]
name = "once_cell"
version = "1.20.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1261fe7e33c73b354eab43b1273a57c8f967d0391e80353e51f764ac02cf6775"
[[package]]
name = "percent-encoding"
version = "2.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e3148f5046208a5d56bcfc03053e3ca6334e51da8dfb19b6cdc8b306fae3283e"
[[package]]
name = "proc-macro2"
version = "1.0.86"
version = "1.0.88"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5e719e8df665df0d1c8fbfd238015744736151d4445ec0836b8e628aae103b77"
checksum = "7c3a7fc5db1e57d5a779a352c8cdb57b29aa4c40cc69c3a68a7fedc815fbf2f9"
dependencies = [
"unicode-ident",
]
@@ -140,21 +223,37 @@ dependencies = [
[[package]]
name = "proxy-wasm"
version = "0.2.2"
source = "git+https://github.com/higress-group/proxy-wasm-rust-sdk?branch=main#73833051f57d483570cf5aaa9d62bd7402fae63b"
source = "git+https://github.com/higress-group/proxy-wasm-rust-sdk?branch=main#8c902102091698bec953471c850bdf9799bc344d"
dependencies = [
"downcast-rs",
"hashbrown",
"log",
]
[[package]]
name = "quote"
version = "1.0.36"
version = "1.0.37"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0fa76aaf39101c457836aec0ce2316dbdc3ab723cdda1c6bd4e6ad4208acaca7"
checksum = "b5b9d34b8991d19d98081b46eacdd8eb58c6f2b201139f7c5f643cc155a633af"
dependencies = [
"proc-macro2",
]
[[package]]
name = "redis"
version = "0.27.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "81cccf17a692ce51b86564334614d72dcae1def0fd5ecebc9f02956da74352b5"
dependencies = [
"arc-swap",
"combine",
"itoa",
"num-bigint",
"percent-encoding",
"ryu",
"url",
]
[[package]]
name = "ryu"
version = "1.0.18"
@@ -163,18 +262,18 @@ checksum = "f3cb5ba0dc43242ce17de99c180e96db90b235b8a9fdc9543c96d2209116bd9f"
[[package]]
name = "serde"
version = "1.0.207"
version = "1.0.211"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5665e14a49a4ea1b91029ba7d3bca9f299e1f7cfa194388ccc20f14743e784f2"
checksum = "1ac55e59090389fb9f0dd9e0f3c09615afed1d19094284d0b200441f13550793"
dependencies = [
"serde_derive",
]
[[package]]
name = "serde_derive"
version = "1.0.207"
version = "1.0.211"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6aea2634c86b0e8ef2cfdc0c340baede54ec27b1e46febd7f80dffb2aa44a00e"
checksum = "54be4f245ce16bc58d57ef2716271d0d4519e0f6defa147f6e081005bcb278ff"
dependencies = [
"proc-macro2",
"quote",
@@ -183,9 +282,9 @@ dependencies = [
[[package]]
name = "serde_json"
version = "1.0.125"
version = "1.0.132"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "83c8e735a073ccf5be70aa8066aa984eaf2fa000db6c8d0100ae605b366d31ed"
checksum = "d726bfaff4b320266d395898905d0eba0345aae23b54aee3a737e260fd46db03"
dependencies = [
"itoa",
"memchr",
@@ -195,9 +294,9 @@ dependencies = [
[[package]]
name = "syn"
version = "2.0.74"
version = "2.0.82"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1fceb41e3d546d0bd83421d3409b1460cc7444cd389341a4c880fe7a042cb3d7"
checksum = "83540f837a8afc019423a8edb95b52a8effe46957ee402287f4292fae35be021"
dependencies = [
"proc-macro2",
"quote",
@@ -205,16 +304,57 @@ dependencies = [
]
[[package]]
name = "unicode-ident"
version = "1.0.12"
name = "tinyvec"
version = "1.8.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3354b9ac3fae1ff6755cb6db53683adb661634f67557942dea4facebec0fee4b"
checksum = "445e881f4f6d382d5f27c034e25eb92edd7c784ceab92a0937db7f2e9471b938"
dependencies = [
"tinyvec_macros",
]
[[package]]
name = "tinyvec_macros"
version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20"
[[package]]
name = "unicode-bidi"
version = "0.3.17"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5ab17db44d7388991a428b2ee655ce0c212e862eff1768a455c58f9aad6e7893"
[[package]]
name = "unicode-ident"
version = "1.0.13"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e91b56cd4cadaeb79bbf1a5645f6b4f8dc5bde8834ad5894a8db35fda9efa1fe"
[[package]]
name = "unicode-normalization"
version = "0.1.24"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5033c97c4262335cded6d6fc3e5c18ab755e1a3dc96376350f3d8e9f009ad956"
dependencies = [
"tinyvec",
]
[[package]]
name = "url"
version = "2.5.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "22784dbdf76fdde8af1aeda5622b546b422b6fc585325248a2bf9f5e41e94d6c"
dependencies = [
"form_urlencoded",
"idna",
"percent-encoding",
]
[[package]]
name = "uuid"
version = "1.10.0"
version = "1.11.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "81dfa00651efa65069b0b6b651f4aaa31ba9e3c3ce0137aaad053604ee7e0314"
checksum = "f8c5f0a0af699448548ad1a2fbf920fb4bee257eae39953ba95cb84891a0446a"
dependencies = [
"getrandom",
]

View File

@@ -13,3 +13,5 @@ uuid = { version = "1.3.3", features = ["v4"] }
multimap = "0"
http = "1"
lazy_static = "1"
downcast-rs="1"
redis={version = "0", default-features = false}

View File

@@ -0,0 +1,38 @@
ARG RUST_VERSION
ARG ORAS_VERSION
ARG HIGRESS_VERSION
ARG BASE_IMAGE=rust:${RUST_VERSION:-1.82}
FROM $BASE_IMAGE
LABEL rust_version=$RUST_VERSION oras_version=$ORAS_VERSION
RUN apt-get update \
&& apt-get install -y wget gcc gcc-multilib llvm clang \
&& rustup target add wasm32-wasi \
&& rm -rf /var/lib/apt/lists/*
RUN arch="$(dpkg --print-architecture)"; arch="${arch##*-}"; \
rust_version=${RUST_VERSION:-1.82}; \
oras_version=${ORAS_VERSION:-1.0.0}; \
higress_version=${HIGRESS_VERSION:-1.0.0-rc}; \
echo "arch: '$arch'"; \
echo "rust rust_version: '$rust_version'"; \
echo "oras_version: '$oras_version'"; \
echo "higress_version: '$higress_version'"; \
case "$arch" in \
'amd64') \
oras_url="https://github.com/oras-project/oras/releases/download/v$oras_version/oras_${oras_version}_linux_amd64.tar.gz"; \
;; \
'arm64') \
oras_url="https://github.com/oras-project/oras/releases/download/v$oras_version/oras_${oras_version}_linux_arm64.tar.gz"; \
;; \
*) echo >&2 "error: unsupported architecture '$arch' "; exit 1 ;; \
esac; \
echo "oras_url: '$oras_url'"; \
wget -O oras.tgz "$oras_url" --progress=dot:giga; \
tar -C /usr/local/bin -xzf oras.tgz && rm -rf oras.tgz; \
echo "done";
ENV PATH=$PATH:/usr/local/bin

View File

@@ -1,17 +1,16 @@
PLUGIN_NAME ?= say-hello
BUILDER_REGISTRY ?= higress-registry.cn-hangzhou.cr.aliyuncs.com/plugins/
REGISTRY ?= higress-registry.cn-hangzhou.cr.aliyuncs.com/plugins/
RUST_VERSION ?= 1.82
ORAS_VERSION ?= 1.0.0
HIGRESS_VERSION ?= 1.0.0-rc
BUILDER ?= ${BUILDER_REGISTRY}wasm-rust-builder:rust${RUST_VERSION}-oras${ORAS_VERSION}
BUILD_TIME := $(shell date "+%Y%m%d-%H%M%S")
COMMIT_ID := $(shell git rev-parse --short HEAD 2>/dev/null)
IMAGE_TAG = $(if $(strip $(PLUGIN_VERSION)),${PLUGIN_VERSION},${BUILD_TIME}-${COMMIT_ID})
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} \
@@ -20,3 +19,21 @@ build:
.
@echo ""
@echo "output wasm file: extensions/${PLUGIN_NAME}/plugin.wasm"
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
builder:
DOCKER_BUILDKIT=1 docker build \
--build-arg RUST_VERSION=$(RUST_VERSION) \
--build-arg ORAS_VERSION=$(ORAS_VERSION) \
--build-arg HIGRESS_VERSION=$(HIGRESS_VERSION) \
-f DockerfileBuilder \
-t ${BUILDER} \
.
@echo ""
@echo "image: ${BUILDER}"

View File

@@ -0,0 +1,270 @@
# 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 = "allocator-api2"
version = "0.2.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5c6cb57a04249c6480766f7f7cef5467412af1490f8d1e243141daddada3264f"
[[package]]
name = "bytes"
version = "1.7.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "428d9aa8fbc0670b7b8d6030a7fadd0f86151cae55e4dbbece15f3780a3dfaf3"
[[package]]
name = "cfg-if"
version = "1.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "baf1de4339761588bc0619e3cbc0120ee582ebb74b53b4efbf79117bd2da40fd"
[[package]]
name = "downcast-rs"
version = "1.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "75b325c5dbd37f80359721ad39aca5a29fb04c89279657cffdda8736d0c0b9d2"
[[package]]
name = "fnv"
version = "1.0.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1"
[[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 = "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 = [
"downcast-rs",
"http",
"lazy_static",
"multimap",
"proxy-wasm",
"serde",
"serde_json",
"uuid",
]
[[package]]
name = "http"
version = "1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "21b9ddb458710bc376481b842f5da65cdf31522de232c1ca8146abce2a358258"
dependencies = [
"bytes",
"fnv",
"itoa",
]
[[package]]
name = "itoa"
version = "1.0.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "49f1f14873335454500d59611f1cf4a4b0f786f9ac11f4312a78e4cf2566695b"
[[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.161"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8e9489c2807c139ffd9c1794f4af0ebe86a828db53ecdc7fea2111d0fed085d1"
[[package]]
name = "log"
version = "0.4.22"
source = "registry+https://github.com/rust-lang/crates.io-index"
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.20.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1261fe7e33c73b354eab43b1273a57c8f967d0391e80353e51f764ac02cf6775"
[[package]]
name = "proc-macro2"
version = "1.0.88"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7c3a7fc5db1e57d5a779a352c8cdb57b29aa4c40cc69c3a68a7fedc815fbf2f9"
dependencies = [
"unicode-ident",
]
[[package]]
name = "proxy-wasm"
version = "0.2.2"
source = "git+https://github.com/higress-group/proxy-wasm-rust-sdk?branch=main#6735737fad486c8a7cc324241f58df4a160e7887"
dependencies = [
"downcast-rs",
"hashbrown",
"log",
]
[[package]]
name = "quote"
version = "1.0.37"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b5b9d34b8991d19d98081b46eacdd8eb58c6f2b201139f7c5f643cc155a633af"
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.210"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c8e3592472072e6e22e0a54d5904d9febf8508f65fb8552499a1abc7d1078c3a"
dependencies = [
"serde_derive",
]
[[package]]
name = "serde_derive"
version = "1.0.210"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "243902eda00fad750862fc144cea25caca5e20d615af0a81bee94ca738f1df1f"
dependencies = [
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "serde_json"
version = "1.0.132"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d726bfaff4b320266d395898905d0eba0345aae23b54aee3a737e260fd46db03"
dependencies = [
"itoa",
"memchr",
"ryu",
"serde",
]
[[package]]
name = "sse-timing"
version = "0.1.0"
dependencies = [
"higress-wasm-rust",
"proxy-wasm",
"serde",
"serde_json",
]
[[package]]
name = "syn"
version = "2.0.82"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "83540f837a8afc019423a8edb95b52a8effe46957ee402287f4292fae35be021"
dependencies = [
"proc-macro2",
"quote",
"unicode-ident",
]
[[package]]
name = "unicode-ident"
version = "1.0.13"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e91b56cd4cadaeb79bbf1a5645f6b4f8dc5bde8834ad5894a8db35fda9efa1fe"
[[package]]
name = "uuid"
version = "1.11.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f8c5f0a0af699448548ad1a2fbf920fb4bee257eae39953ba95cb84891a0446a"
dependencies = [
"getrandom",
]
[[package]]
name = "version_check"
version = "0.9.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
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

@@ -0,0 +1,15 @@
[package]
name = "sse-timing"
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"

View File

@@ -0,0 +1,10 @@
BUILD_OPTS="--release"
.DEFAULT:
build:
cargo build --target wasm32-wasi ${BUILD_OPTS}
find target -name "*.wasm" -d 3 -exec cp "{}" plugin.wasm \;
clean:
cargo clean
rm -f plugin.wasm

View File

@@ -0,0 +1,26 @@
## Proxy-Wasm plugin example: SSE Timing
Proxy-Wasm plugin that traces Server-Side Event(SSE) duration from request start.
### Building
```sh
$ make
```
### Using in Envoy
This example can be run with [`docker compose`](https://docs.docker.com/compose/install/)
and has a matching Envoy configuration.
```sh
$ docker compose up
```
#### Access granted.
Send HTTP request to `localhost:10000/`:
```sh
$ curl localhost:10000/
```

View File

@@ -0,0 +1,35 @@
# 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.
services:
envoy:
image: higress-registry.cn-hangzhou.cr.aliyuncs.com/higress/all-in-one:latest
entrypoint: /usr/local/bin/envoy
command: -c /etc/envoy/envoy.yaml --component-log-level wasm:debug
depends_on:
- sse-server
hostname: envoy
ports:
- "10000:10000"
volumes:
- ./envoy.yaml:/etc/envoy/envoy.yaml
- ./target/wasm32-wasi/release:/etc/envoy/proxy-wasm-plugins
networks:
- envoymesh
sse-server:
build: sse-server
networks:
- envoymesh
networks:
envoymesh: {}

View File

@@ -0,0 +1,76 @@
# 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.
static_resources:
listeners:
- name: listener_0
address:
socket_address:
protocol: TCP
address: 0.0.0.0
port_value: 10000
filter_chains:
- filters:
- name: envoy.filters.network.http_connection_manager
typed_config:
"@type": type.googleapis.com/envoy.extensions.filters.network.http_connection_manager.v3.HttpConnectionManager
stat_prefix: ingress_http
route_config:
name: local_route
virtual_hosts:
- name: local_service
domains: ["*"]
routes:
- match:
prefix: "/"
route:
cluster: sse-server
http_filters:
- name: envoy.filters.http.wasm
typed_config:
"@type": type.googleapis.com/udpa.type.v1.TypedStruct
type_url: type.googleapis.com/envoy.extensions.filters.http.wasm.v3.Wasm
value:
config:
name: "http_body"
configuration:
"@type": type.googleapis.com/google.protobuf.StringValue
value: |-
{
"name": "sse_timing",
"_rules_": []
}
vm_config:
runtime: "envoy.wasm.runtime.v8"
code:
local:
filename: "/etc/envoy/proxy-wasm-plugins/sse_timing.wasm"
- name: envoy.filters.http.router
typed_config:
"@type": type.googleapis.com/envoy.extensions.filters.http.router.v3.Router
clusters:
- name: sse-server
connect_timeout: 30s
type: LOGICAL_DNS
# dns_lookup_family: V4_ONLY
lb_policy: ROUND_ROBIN
load_assignment:
cluster_name: sse-server
endpoints:
- lb_endpoints:
- endpoint:
address:
socket_address:
address: sse-server
port_value: 8080

View File

@@ -0,0 +1,198 @@
// 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 higress_wasm_rust::event_stream::EventStream;
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::{ContextType, DataAction, HeaderAction, LogLevel};
use serde::Deserialize;
use std::cell::RefCell;
use std::ops::DerefMut;
use std::rc::Rc;
use std::str::from_utf8;
use std::time::{Duration, SystemTime};
proxy_wasm::main! {{
proxy_wasm::set_log_level(LogLevel::Trace);
proxy_wasm::set_root_context(|_|Box::new(SseTimingRoot::new()));
}}
struct SseTimingRoot {
log: Rc<Log>,
rule_matcher: SharedRuleMatcher<SseTimingConfig>,
}
struct SseTiming {
log: Rc<Log>,
rule_matcher: SharedRuleMatcher<SseTimingConfig>,
vendor: String,
is_event_stream: bool,
event_stream: EventStream,
start_time: SystemTime,
}
#[derive(Default, Clone, Debug, Deserialize)]
struct SseTimingConfig {
vendor: Option<String>,
}
impl SseTimingRoot {
fn new() -> Self {
SseTimingRoot {
log: Rc::new(Log::new("sse_timing".to_string())),
rule_matcher: Rc::new(RefCell::new(RuleMatcher::default())),
}
}
}
impl Context for SseTimingRoot {}
impl RootContext for SseTimingRoot {
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>> {
Some(Box::new(SseTiming {
log: self.log.clone(),
rule_matcher: self.rule_matcher.clone(),
vendor: "higress".into(),
is_event_stream: false,
event_stream: EventStream::new(),
start_time: self.get_current_time(),
}))
}
fn get_type(&self) -> Option<ContextType> {
Some(ContextType::HttpContext)
}
}
impl Context for SseTiming {}
impl HttpContext for SseTiming {
fn on_http_request_headers(
&mut self,
_num_headers: usize,
_end_of_stream: bool,
) -> HeaderAction {
self.start_time = self.get_current_time();
let binding = self.rule_matcher.borrow();
let config = match binding.get_match_config() {
None => {
return HeaderAction::Continue;
}
Some(config) => config.1,
};
match config.vendor.clone() {
None => {}
Some(vendor) => self.vendor = vendor,
}
HeaderAction::Continue
}
fn on_http_response_headers(
&mut self,
_num_headers: usize,
_end_of_stream: bool,
) -> HeaderAction {
match self.get_http_response_header("Content-Type") {
None => self
.log
.warn("upstream response is not set Content-Type, skipped"),
Some(content_type) => {
if content_type.starts_with("text/event-stream") {
self.is_event_stream = true
} else {
self.log.warn(format!("upstream response Content-Type is not text/event-stream, but {}, skipped", content_type).as_str())
}
}
}
HeaderAction::Continue
}
fn on_http_response_body(&mut self, body_size: usize, end_of_stream: bool) -> DataAction {
if !self.is_event_stream {
return DataAction::Continue;
}
let body = self
.get_http_response_body(0, body_size)
.unwrap_or_default();
self.event_stream.update(body);
self.process_event_stream(end_of_stream)
}
}
impl SseTiming {
fn process_event_stream(&mut self, end_of_stream: bool) -> DataAction {
let mut modified_events = Vec::new();
loop {
match self.event_stream.next() {
None => break,
Some(raw_event) => {
if !raw_event.is_empty() {
// according to spec, event-stream must be utf-8 encoding
let event = from_utf8(raw_event.as_slice()).unwrap();
let processed_event = self.process_event(event.to_string());
modified_events.push(processed_event);
}
}
}
}
if end_of_stream {
match self.event_stream.flush() {
None => {}
Some(raw_event) => {
if !raw_event.is_empty() {
// according to spec, event-stream must be utf-8 encoding
let event = from_utf8(raw_event.as_slice()).unwrap();
let modified_event = self.process_event(event.into());
modified_events.push(modified_event);
}
}
}
}
if !modified_events.is_empty() {
let modified_body = modified_events.concat();
self.set_http_response_body(0, modified_body.len(), modified_body.as_bytes());
DataAction::Continue
} else {
DataAction::StopIterationNoBuffer
}
}
fn process_event(&self, event: String) -> String {
let duration = self
.get_current_time()
.duration_since(self.start_time)
.unwrap_or(Duration::ZERO);
format!(
": server-timing: {};dur={}\n{}\n\n",
self.vendor,
duration.as_millis(),
event
)
}
}

View File

@@ -0,0 +1,5 @@
FROM golang:latest AS builder
WORKDIR /workspace
COPY . .
RUN GOOS=linux GOARCH=amd64 go build -o main .
CMD ./main

Some files were not shown because too many files have changed in this diff Show More