diff --git a/backend/sdk/src/main/java/com/alibaba/higress/sdk/service/kubernetes/KubernetesModelConverter.java b/backend/sdk/src/main/java/com/alibaba/higress/sdk/service/kubernetes/KubernetesModelConverter.java index 792946ff..2aef2c19 100644 --- a/backend/sdk/src/main/java/com/alibaba/higress/sdk/service/kubernetes/KubernetesModelConverter.java +++ b/backend/sdk/src/main/java/com/alibaba/higress/sdk/service/kubernetes/KubernetesModelConverter.java @@ -32,6 +32,7 @@ import java.util.Optional; import java.util.Set; import java.util.regex.Pattern; +import java.util.stream.Collectors; import javax.naming.InvalidNameException; import javax.naming.ldap.LdapName; @@ -1208,21 +1209,32 @@ private void fillIngressHeaderControlStageConfig(V1ObjectMeta metadata, HeaderCo } if (CollectionUtils.isNotEmpty(config.getAdd())) { setFunctionalAnnotation(metadata, addKey, - StringUtils.join(config.getAdd().stream().map(this::getHeaderConfig).toList(), Separators.NEW_LINE), + StringUtils.join(config.getAdd().stream().map(this::getHeaderConfig).filter(Objects::nonNull).toList(), + Separators.NEW_LINE), enabled); } if (CollectionUtils.isNotEmpty(config.getSet())) { setFunctionalAnnotation(metadata, setKey, - StringUtils.join(config.getSet().stream().map(this::getHeaderConfig).toList(), Separators.NEW_LINE), + StringUtils.join(config.getSet().stream().map(this::getHeaderConfig).filter(Objects::nonNull).toList(), + Separators.NEW_LINE), enabled); } if (CollectionUtils.isNotEmpty(config.getRemove())) { - setFunctionalAnnotation(metadata, removeKey, StringUtils.join(config.getRemove(), Separators.COMMA), + setFunctionalAnnotation(metadata, removeKey, + StringUtils.join( + config.getRemove().stream().filter(StringUtils::isNotEmpty).collect(Collectors.toList()), + Separators.COMMA), enabled); } } private String getHeaderConfig(Header header) { + if (StringUtils.isEmpty(header.getKey())) { + return null; + } + if (StringUtils.isEmpty(header.getValue())) { + return header.getKey() + Separators.SPACE; + } return header.getKey() + Separators.SPACE + header.getValue(); } @@ -1273,7 +1285,7 @@ private void fillIngressTls(V1ObjectMeta metadata, V1IngressSpec spec, Route rou } V1IngressTLS tls = new V1IngressTLS(); - if (!HigressConstants.DEFAULT_DOMAIN.equals(domainName)){ + if (!HigressConstants.DEFAULT_DOMAIN.equals(domainName)) { tls.setHosts(Collections.singletonList(domainName)); } tls.setSecretName(domain.getCertIdentifier()); @@ -1602,6 +1614,9 @@ private static String buildImageUrl(String imageRepository, String imageVersion) } private void setQueryAnnotation(V1ObjectMeta metadata, KeyedRoutePredicate keyedRoutePredicate) { + if (StringUtils.isAnyBlank(keyedRoutePredicate.getMatchType(), keyedRoutePredicate.getKey(), keyedRoutePredicate.getMatchValue())){ + return; + } RoutePredicateTypeEnum predicateType = RoutePredicateTypeEnum.valueOf(keyedRoutePredicate.getMatchType()); String annotationName = String.format(KubernetesConstants.Annotation.QUERY_MATCH_KEY_FORMAT, predicateType.getAnnotationPrefix(), keyedRoutePredicate.getKey()); @@ -1609,6 +1624,9 @@ private void setQueryAnnotation(V1ObjectMeta metadata, KeyedRoutePredicate keyed } private void setHeaderAnnotation(V1ObjectMeta metadata, KeyedRoutePredicate keyedRoutePredicate) { + if (StringUtils.isAnyBlank(keyedRoutePredicate.getMatchType(), keyedRoutePredicate.getKey(), keyedRoutePredicate.getMatchValue())){ + return; + } RoutePredicateTypeEnum predicateType = RoutePredicateTypeEnum.valueOf(keyedRoutePredicate.getMatchType()); String key = keyedRoutePredicate.getKey(); String format = KubernetesConstants.Annotation.HEADER_MATCH_KEY_FORMAT; diff --git a/backend/sdk/src/main/resources/plugins/ai-agent/README.md b/backend/sdk/src/main/resources/plugins/ai-agent/README.md index 38915a10..0513ef1c 100644 --- a/backend/sdk/src/main/resources/plugins/ai-agent/README.md +++ b/backend/sdk/src/main/resources/plugins/ai-agent/README.md @@ -5,14 +5,15 @@ description: AI Agent插件配置参考 --- ## 功能说明 -一个可定制化的 API AI Agent,支持配置 http method 类型为 GET 与 POST 的 API,目前只支持非流式模式。 +一个可定制化的 API AI Agent,支持配置 http method 类型为 GET 与 POST 的 API,支持多轮对话,支持流式与非流式模式。 agent流程图如下: -![ai-agent](https://github.com/user-attachments/assets/b0761a0c-1afa-496c-a98e-bb9f38b340f8) +![ai-agent](https://img.alicdn.com/imgextra/i1/O1CN01PGSDW31WQfEPm173u_!!6000000002783-0-tps-2733-1473.jpg) + ## 运行属性 插件执行阶段:`默认阶段` -插件执行优先级:`20` +插件执行优先级:`200` ## 配置字段 @@ -46,18 +47,19 @@ agent流程图如下: `apiProvider`的配置字段说明如下: -| 名称 | 数据类型 | 填写要求 | 默认值 | 描述 | -|-----------------|-----------|---------|--------|------------------------------------------| -| `apiKey` | object | 非必填 | - | 用于在访问外部 API 服务时进行认证的令牌。 | -| `serviceName` | string | 必填 | - | 访问外部 API 服务名 | -| `servicePort` | int | 必填 | - | 访问外部 API 服务端口 | -| `domain` | string | 必填 | - | 访访问外部 API 时域名 | +| 名称 | 数据类型 | 填写要求 | 默认值 | 描述 | +|-------------------|-----------|---------|--------|------------------------------------------| +| `apiKey` | object | 非必填 | - | 用于在访问外部 API 服务时进行认证的令牌。 | +| `maxExecutionTime`| int | 非必填 | 50000 | 每一次请求API的超时时间,单位毫秒。 | +| `serviceName` | string | 必填 | - | 访问外部 API 服务名 | +| `servicePort` | int | 必填 | - | 访问外部 API 服务端口 | +| `domain` | string | 必填 | - | 访访问外部 API 时域名 | `apiKey`的配置字段说明如下: | 名称 | 数据类型 | 填写要求 | 默认值 | 描述 | -|-------------------|---------|------------|--------|-------------------------------------------------------------------------------| -| `in` | string | 非必填 | header | 在访问外部 API 服务时进行认证的令牌是放在 header 中还是放在 query 中,默认是 header。 +|-------------------|---------|------------|--------|-----------------------------------------------------------------------------------------| +| `in` | string | 非必填 | none | 在访问外部 API 服务时进行认证的令牌是放在 header 中还是放在 query 中,如果API没有令牌,填none。 | `name` | string | 非必填 | - | 用于在访问外部 API 服务时进行认证的令牌的名称。 | | `value` | string | 非必填 | - | 用于在访问外部 API 服务时进行认证的令牌的值。 | @@ -75,11 +77,8 @@ agent流程图如下: |-----------------|-----------|-----------|--------|---------------------------------------------| | `question` | string | 非必填 | - | Agent ReAct 模板的 question 部分 | | `thought1` | string | 非必填 | - | Agent ReAct 模板的 thought1 部分 | -| `actionInput` | string | 非必填 | - | Agent ReAct 模板的 actionInput 部分 | | `observation` | string | 非必填 | - | Agent ReAct 模板的 observation 部分 | | `thought2` | string | 非必填 | - | Agent ReAct 模板的 thought2 部分 | -| `finalAnswer` | string | 非必填 | - | Agent ReAct 模板的 finalAnswer 部分 | -| `begin` | string | 非必填 | - | Agent ReAct 模板的 begin 部分 | ## 用法示例 @@ -325,6 +324,21 @@ curl 'http://<这里换成网关公网IP>/api/openai/v1/chat/completions' \ **请求示例** +```shell +curl 'http://<这里换成网关公网IP>/api/openai/v1/chat/completions' \ +-H 'Accept: application/json, text/event-stream' \ +-H 'Content-Type: application/json' \ +--data-raw '{"model":"qwen","frequency_penalty":0,"max_tokens":800,"stream":false,"messages":[{"role": "user","content": "济南的天气如何?"},{ "role": "assistant","content": "目前,济南市的天气为多云,气温为24℃,数据更新时间为2024年9月12日21时50分14秒。"},{"role": "user","content": "北京呢?"}],"presence_penalty":0,"temperature":0,"top_p":0}' +``` + +**响应示例** + +```json +{"id":"ebd6ea91-8e38-9e14-9a5b-90178d2edea4","choices":[{"index":0,"message":{"role":"assistant","content":"目前,北京市的天气为晴朗,气温为19℃,数据更新时间为2024年9月12日22时17分40秒。"},"finish_reason":"stop"}],"created":1723187991,"model":"qwen-max-0403","object":"chat.completion","usage":{"prompt_tokens":999,"completion_tokens":76,"total_tokens":1075}} +``` + +**请求示例** + ```shell curl 'http://<这里换成网关公网IP>/api/openai/v1/chat/completions' \ -H 'Accept: application/json, text/event-stream' \ diff --git a/backend/sdk/src/main/resources/plugins/ai-agent/README_EN.md b/backend/sdk/src/main/resources/plugins/ai-agent/README_EN.md index 4d818f9a..86448231 100644 --- a/backend/sdk/src/main/resources/plugins/ai-agent/README_EN.md +++ b/backend/sdk/src/main/resources/plugins/ai-agent/README_EN.md @@ -4,13 +4,13 @@ keywords: [ AI Gateway, AI Agent ] description: AI Agent plugin configuration reference --- ## Functional Description -A customizable API AI Agent that supports configuring HTTP method types as GET and POST APIs. Currently, it only supports non-streaming mode. +A customizable API AI Agent that supports configuring HTTP method types as GET and POST APIs. Supports multiple dialogue rounds, streaming and non-streaming modes. The agent flow chart is as follows: ![ai-agent](https://github.com/user-attachments/assets/b0761a0c-1afa-496c-a98e-bb9f38b340f8) ## Runtime Properties -Plugin execution phase: `Default Phase` -Plugin execution priority: `20` +Plugin execution phase: `Default Phase` +Plugin execution priority: `200` ## Configuration Fields @@ -41,17 +41,18 @@ The configuration fields for `apis` are as follows: | `api` | string | Required | - | OpenAPI documentation of the tool | The configuration fields for `apiProvider` are as follows: -| Name | Data Type | Requirement | Default Value | Description | -|-----------------|-----------|-------------|---------------|--------------------------------------------------| -| `apiKey` | object | Optional | - | Token for authentication when accessing external API services. | -| `serviceName` | string | Required | - | Name of the external API service | -| `servicePort` | int | Required | - | Port of the external API service | -| `domain` | string | Required | - | Domain for accessing the external API | +| Name | Data Type | Requirement | Default Value | Description | +|-------------------|-----------|-------------|---------------|--------------------------------------------------| +| `apiKey` | object | Optional | - | Token for authentication when accessing external API services. | +| `maxExecutionTime`| int | Optional | 50000 | Timeout for each request to the API, in milliseconds| +| `serviceName` | string | Required | - | Name of the external API service | +| `servicePort` | int | Required | - | Port of the external API service | +| `domain` | string | Required | - | Domain for accessing the external API | The configuration fields for `apiKey` are as follows: | Name | Data Type | Requirement | Default Value | Description | |-------------------|-----------|-------------|---------------|-------------------------------------------------------------------------------------| -| `in` | string | Optional | header | Whether the authentication token for accessing the external API service is in the header or in the query; default is header. | +| `in` | string | Optional | none | Whether the authentication token for accessing the external API service is in the header or in the query; If the API does not have a token, fill in none. | | `name` | string | Optional | - | The name of the token for authentication when accessing the external API service. | | `value` | string | Optional | - | The value of the token for authentication when accessing the external API service. | @@ -67,11 +68,8 @@ The configuration fields for `chTemplate` and `enTemplate` are as follows: |-----------------|-----------|-------------|---------------|---------------------------------------------------| | `question` | string | Optional | - | The question part of the Agent ReAct template | | `thought1` | string | Optional | - | The thought1 part of the Agent ReAct template | -| `actionInput` | string | Optional | - | The actionInput part of the Agent ReAct template | | `observation` | string | Optional | - | The observation part of the Agent ReAct template | | `thought2` | string | Optional | - | The thought2 part of the Agent ReAct template | -| `finalAnswer` | string | Optional | - | The finalAnswer part of the Agent ReAct template | -| `begin` | string | Optional | - | The begin part of the Agent ReAct template | ## Usage Example **Configuration Information** @@ -308,6 +306,17 @@ curl 'http:///api/openai/v1/chat/completions' \ curl 'http:///api/openai/v1/chat/completions' \ -H 'Accept: application/json, text/event-stream' \ -H 'Content-Type: application/json' \ +--data-raw '{"model":"qwen","frequency_penalty":0,"max_tokens":800,"stream":false,"messages":[{"role":"user","content":"What is the current weather in Jinan?"},{"role":"assistant","content":" The current weather condition in Jinan is overcast, with a temperature of 31°C. This information was last updated on August 9, 2024, at 15:12 (Beijing time)."},{"role":"user","content":"BeiJing?"}],"presence_penalty":0,"temperature":0,"top_p":0}' +``` +**Response Example** +```json +{"id":"ebd6ea91-8e38-9e14-9a5b-90178d2edea4","choices":[{"index":0,"message":{"role":"assistant","content":" The current weather condition in Beijing is overcast, with a temperature of 19°C. This information was last updated on Sep 12, 2024, at 22:17 (Beijing time)."},"finish_reason":"stop"}],"created":1723187991,"model":"qwen-max-0403","object":"chat.completion","usage":{"prompt_tokens":999,"completion_tokens":76,"total_tokens":1075}} +``` +**Request Example** +```shell +curl 'http:///api/openai/v1/chat/completions' \ +-H 'Accept: application/json, text/event-stream' \ +-H 'Content-Type: application/json' \ --data-raw '{"model":"qwen","frequency_penalty":0,"max_tokens":800,"stream":false,"messages":[{"role":"user","content":"What is the current weather in Jinan? Please indicate in Fahrenheit and respond in Japanese."}],"presence_penalty":0,"temperature":0,"top_p":0}' ``` **Response Example** diff --git a/backend/sdk/src/main/resources/plugins/ai-agent/spec.yaml b/backend/sdk/src/main/resources/plugins/ai-agent/spec.yaml index 2b1ebeca..7d8c599f 100644 --- a/backend/sdk/src/main/resources/plugins/ai-agent/spec.yaml +++ b/backend/sdk/src/main/resources/plugins/ai-agent/spec.yaml @@ -17,7 +17,7 @@ info: gatewayMinVersion: "" spec: phase: default - priority: 20 + priority: 200 routeConfigSchema: openAPIV3Schema: type: object diff --git a/backend/sdk/src/main/resources/plugins/ai-proxy/README.md b/backend/sdk/src/main/resources/plugins/ai-proxy/README.md index dca2f1b4..c519f293 100644 --- a/backend/sdk/src/main/resources/plugins/ai-proxy/README.md +++ b/backend/sdk/src/main/resources/plugins/ai-proxy/README.md @@ -215,6 +215,10 @@ DeepL 所对应的 `type` 为 `deepl`。它特有的配置字段如下: | ------------ | -------- | -------- | ------ | ---------------------------- | | `targetLang` | string | 必填 | - | DeepL 翻译服务需要的目标语种 | +#### Cohere + +Cohere 所对应的 `type` 为 `cohere`。它并无特有的配置字段。 + ## 用法示例 ### 使用 OpenAI 协议代理 Azure OpenAI 服务 @@ -231,90 +235,6 @@ provider: azureServiceUrl: "https://YOUR_RESOURCE_NAME.openai.azure.com/openai/deployments/YOUR_DEPLOYMENT_NAME/chat/completions?api-version=2024-02-15-preview", ``` -**请求示例** - -```json -{ - "model": "gpt-3", - "messages": [ - { - "role": "user", - "content": "你好,你是谁?" - } - ], - "temperature": 0.3 -} -``` - -**响应示例** - -```json -{ - "choices": [ - { - "content_filter_results": { - "hate": { - "filtered": false, - "severity": "safe" - }, - "self_harm": { - "filtered": false, - "severity": "safe" - }, - "sexual": { - "filtered": false, - "severity": "safe" - }, - "violence": { - "filtered": false, - "severity": "safe" - } - }, - "finish_reason": "stop", - "index": 0, - "logprobs": null, - "message": { - "content": "你好!我是一个AI助手,可以回答你的问题和提供帮助。有什么我可以帮到你的吗?", - "role": "assistant" - } - } - ], - "created": 1714807624, - "id": "chatcmpl-abcdefg1234567890", - "model": "gpt-35-turbo-16k", - "object": "chat.completion", - "prompt_filter_results": [ - { - "prompt_index": 0, - "content_filter_results": { - "hate": { - "filtered": false, - "severity": "safe" - }, - "self_harm": { - "filtered": false, - "severity": "safe" - }, - "sexual": { - "filtered": false, - "severity": "safe" - }, - "violence": { - "filtered": false, - "severity": "safe" - } - } - } - ], - "system_fingerprint": null, - "usage": { - "completion_tokens": 40, - "prompt_tokens": 15, - "total_tokens": 55 - } -} -``` - ### 使用 OpenAI 协议代理通义千问服务 使用通义千问服务,并配置从 OpenAI 大模型到通义千问的模型映射关系。 @@ -336,270 +256,29 @@ provider: '*': "qwen-turbo" ``` -**AI 对话请求示例** - -URL: http://your-domain/v1/chat/completions - -请求示例: - -```json -{ - "model": "gpt-3", - "messages": [ - { - "role": "user", - "content": "你好,你是谁?" - } - ], - "temperature": 0.3 -} -``` - -响应示例: - -```json -{ - "id": "c2518bd3-0f46-97d1-be34-bb5777cb3108", - "choices": [ - { - "index": 0, - "message": { - "role": "assistant", - "content": "我是通义千问,由阿里云开发的AI助手。我可以回答各种问题、提供信息和与用户进行对话。有什么我可以帮助你的吗?" - }, - "finish_reason": "stop" - } - ], - "created": 1715175072, - "model": "qwen-turbo", - "object": "chat.completion", - "usage": { - "prompt_tokens": 24, - "completion_tokens": 33, - "total_tokens": 57 - } -} -``` - -**多模态模型 API 请求示例(适用于 `qwen-vl-plus` 和 `qwen-vl-max` 模型)** - -URL: http://your-domain/v1/chat/completions - -请求示例: - -```json -{ - "model": "gpt-4o", - "messages": [ - { - "role": "user", - "content": [ - { - "type": "image_url", - "image_url": { - "url": "https://dashscope.oss-cn-beijing.aliyuncs.com/images/dog_and_girl.jpeg" - } - }, - { - "type": "text", - "text": "这个图片是哪里?" - } - ] - } - ], - "temperature": 0.3 -} -``` - -响应示例: - -```json -{ - "id": "17c5955d-af9c-9f28-bbde-293a9c9a3515", - "choices": [ - { - "index": 0, - "message": { - "role": "assistant", - "content": [ - { - "text": "这张照片显示的是一位女士和一只狗在海滩上。由于我无法获取具体的地理位置信息,所以不能确定这是哪个地方的海滩。但是从视觉内容来看,它可能是一个位于沿海地区的沙滩海岸线,并且有海浪拍打着岸边。这样的场景在全球许多美丽的海滨地区都可以找到。如果您需要更精确的信息,请提供更多的背景或细节描述。" - } - ] - }, - "finish_reason": "stop" - } - ], - "created": 1723949230, - "model": "qwen-vl-plus", - "object": "chat.completion", - "usage": { - "prompt_tokens": 1279, - "completion_tokens": 78 - } -} -``` - -**文本向量请求示例** - -URL: http://your-domain/v1/embeddings - -请求示例: - -```json -{ - "model": "text-embedding-v1", - "input": "Hello" -} -``` - -响应示例: - -```json -{ - "object": "list", - "data": [ - { - "object": "embedding", - "index": 0, - "embedding": [ - -1.0437825918197632, - 5.208984375, - 3.0483806133270264, - -1.7897135019302368, - -2.0107421875, - ..., - 0.8125, - -1.1759847402572632, - 0.8174641728401184, - 1.0432943105697632, - -0.5885213017463684 - ] - } - ], - "model": "text-embedding-v1", - "usage": { - "prompt_tokens": 1, - "total_tokens": 1 - } -} -``` - -### 使用通义千问配合纯文本上下文信息 - -使用通义千问服务,同时配置纯文本上下文信息。 - +### 使用original协议代理百炼智能体应用 **配置信息** ```yaml provider: type: qwen apiTokens: - - "YOUR_QWEN_API_TOKEN" - modelMapping: - "*": "qwen-turbo" - context: - - fileUrl: "http://file.default.svc.cluster.local/ai/context.txt", - serviceName: "file.dns", - servicePort: 80 -``` - -**请求示例** - -```json -{ - "model": "gpt-3", - "messages": [ - { - "role": "user", - "content": "请概述文案内容" - } - ], - "temperature": 0.3 -} + - "YOUR_DASHSCOPE_API_TOKEN" + protocol: original ``` -**响应示例** - -```json -{ - "id": "cmpl-77861a17681f4987ab8270dbf8001936", - "object": "chat.completion", - "created": 9756990, - "model": "moonshot-v1-128k", - "choices": [ - { - "index": 0, - "message": { - "role": "assistant", - "content": "这份文案是一份关于..." - }, - "finish_reason": "stop" - } - ], - "usage": { - "prompt_tokens": 20181, - "completion_tokens": 439, - "total_tokens": 20620 - } -} -``` - -### 使用通义千问配合其原生的文件上下文 - -提前上传文件至通义千问,以文件内容作为上下文使用其 AI 服务。 +### 使用 OpenAI 协议代理豆包大模型服务 **配置信息** ```yaml provider: - type: qwen + type: doubao apiTokens: - - "YOUR_QWEN_API_TOKEN" + - YOUR_DOUBAO_API_KEY modelMapping: - "*": "qwen-long" # 通义千问的文件上下文只能在 qwen-long 模型下使用 - qwenFileIds: - - "file-fe-xxx" - - "file-fe-yyy" -``` - -**请求示例** - -```json -{ - "model": "gpt-4-turbo", - "messages": [ - { - "role": "user", - "content": "请概述文案内容" - } - ], - "temperature": 0.3 -} -``` - -**响应示例** - -```json -{ - "output": { - "choices": [ - { - "finish_reason": "stop", - "message": { - "role": "assistant", - "content": "您上传了两个文件,`context.txt` 和 `context_2.txt`,它们似乎都包含了关于xxxx" - } - } - ] - }, - "usage": { - "total_tokens": 2023, - "output_tokens": 530, - "input_tokens": 1493 - }, - "request_id": "187e99ba-5b64-9ffe-8f69-01dafbaf6ed7" -} + '*': YOUR_DOUBAO_ENDPOINT + timeout: 1200000 ``` ### 使用月之暗面配合其原生的文件上下文 @@ -618,47 +297,6 @@ provider: '*': "moonshot-v1-32k" ``` -**请求示例** - -```json -{ - "model": "gpt-4-turbo", - "messages": [ - { - "role": "user", - "content": "请概述文案内容" - } - ], - "temperature": 0.3 -} -``` - -**响应示例** - -```json -{ - "id": "cmpl-e5ca873642ca4f5d8b178c1742f9a8e8", - "object": "chat.completion", - "created": 1872961, - "model": "moonshot-v1-128k", - "choices": [ - { - "index": 0, - "message": { - "role": "assistant", - "content": "文案内容是关于一个名为“xxxx”的支付平台..." - }, - "finish_reason": "stop" - } - ], - "usage": { - "prompt_tokens": 11, - "completion_tokens": 498, - "total_tokens": 509 - } -} -``` - ### 使用 OpenAI 协议代理 Groq 服务 **配置信息** @@ -670,54 +308,6 @@ provider: - "YOUR_GROQ_API_TOKEN" ``` -**请求示例** - -```json -{ - "model": "llama3-8b-8192", - "messages": [ - { - "role": "user", - "content": "你好,你是谁?" - } - ] -} -``` - -**响应示例** - -```json -{ - "id": "chatcmpl-26733989-6c52-4056-b7a9-5da791bd7102", - "object": "chat.completion", - "created": 1715917967, - "model": "llama3-8b-8192", - "choices": [ - { - "index": 0, - "message": { - "role": "assistant", - "content": "Ni Hao! (That's \"hello\" in Chinese!)\n\nI am LLaMA, an AI assistant developed by Meta AI that can understand and respond to human input in a conversational manner. I'm not a human, but a computer program designed to simulate conversations and answer questions to the best of my ability. I'm happy to chat with you in Chinese or help with any questions or topics you'd like to discuss!" - }, - "logprobs": null, - "finish_reason": "stop" - } - ], - "usage": { - "prompt_tokens": 16, - "prompt_time": 0.005, - "completion_tokens": 89, - "completion_time": 0.104, - "total_tokens": 105, - "total_time": 0.109 - }, - "system_fingerprint": "fp_dadc9d6142", - "x_groq": { - "id": "req_01hy2awmcxfpwbq56qh6svm7qz" - } -} -``` - ### 使用 OpenAI 协议代理 Claude 服务 **配置信息** @@ -730,46 +320,6 @@ provider: version: "2023-06-01" ``` -**请求示例** - -```json -{ - "model": "claude-3-opus-20240229", - "max_tokens": 1024, - "messages": [ - { - "role": "user", - "content": "你好,你是谁?" - } - ] -} -``` - -**响应示例** - -```json -{ - "id": "msg_01Jt3GzyjuzymnxmZERJguLK", - "choices": [ - { - "index": 0, - "message": { - "role": "assistant", - "content": "您好,我是一个由人工智能公司Anthropic开发的聊天助手。我的名字叫Claude,是一个聪明友善、知识渊博的对话系统。很高兴认识您!我可以就各种话题与您聊天,回答问题,提供建议和帮助。我会尽最大努力给您有帮助的回复。希望我们能有个愉快的交流!" - }, - "finish_reason": "stop" - } - ], - "created": 1717385918, - "model": "claude-3-opus-20240229", - "object": "chat.completion", - "usage": { - "prompt_tokens": 16, - "completion_tokens": 126, - "total_tokens": 142 - } -} -``` ### 使用 OpenAI 协议代理混元服务 **配置信息** @@ -786,55 +336,6 @@ provider: "*": "hunyuan-lite" ``` -**请求示例** -请求脚本: -```sh - -curl --location 'http:///v1/chat/completions' \ ---header 'Content-Type: application/json' \ ---data '{ - "model": "gpt-3", - "messages": [ - { - "role": "system", - "content": "你是一个名专业的开发人员!" - }, - { - "role": "user", - "content": "你好,你是谁?" - } - ], - "temperature": 0.3, - "stream": false -}' -``` - -**响应示例** - -```json -{ - "id": "fd140c3e-0b69-4b19-849b-d354d32a6162", - "choices": [ - { - "index": 0, - "delta": { - "role": "assistant", - "content": "你好!我是一名专业的开发人员。" - }, - "finish_reason": "stop" - } - ], - "created": 1717493117, - "model": "hunyuan-lite", - "object": "chat.completion", - "usage": { - "prompt_tokens": 15, - "completion_tokens": 9, - "total_tokens": 24 - } -} -``` - ### 使用 OpenAI 协议代理百度文心一言服务 **配置信息** @@ -849,47 +350,6 @@ provider: '*': "ERNIE-4.0" ``` -**请求示例** - -```json -{ - "model": "gpt-4-turbo", - "messages": [ - { - "role": "user", - "content": "你好,你是谁?" - } - ], - "stream": false -} -``` - -**响应示例** - -```json -{ - "id": "as-e90yfg1pk1", - "choices": [ - { - "index": 0, - "message": { - "role": "assistant", - "content": "你好,我是文心一言,英文名是ERNIE Bot。我能够与人对话互动,回答问题,协助创作,高效便捷地帮助人们获取信息、知识和灵感。" - }, - "finish_reason": "stop" - } - ], - "created": 1717251488, - "model": "ERNIE-4.0", - "object": "chat.completion", - "usage": { - "prompt_tokens": 4, - "completion_tokens": 33, - "total_tokens": 37 - } -} -``` - ### 使用 OpenAI 协议代理MiniMax服务 **配置信息** @@ -906,53 +366,6 @@ provider: minimaxGroupId: "YOUR_MINIMAX_GROUP_ID" ``` -**请求示例** - -```json -{ - "model": "gpt-4-turbo", - "messages": [ - { - "role": "user", - "content": "你好,你是谁?" - } - ], - "stream": false -} -``` - -**响应示例** - -```json -{ - "id": "02b2251f8c6c09d68c1743f07c72afd7", - "choices": [ - { - "finish_reason": "stop", - "index": 0, - "message": { - "content": "你好!我是MM智能助理,一款由MiniMax自研的大型语言模型。我可以帮助你解答问题,提供信息,进行对话等。有什么可以帮助你的吗?", - "role": "assistant" - } - } - ], - "created": 1717760544, - "model": "abab6.5s-chat", - "object": "chat.completion", - "usage": { - "total_tokens": 106 - }, - "input_sensitive": false, - "output_sensitive": false, - "input_sensitive_type": 0, - "output_sensitive_type": 0, - "base_resp": { - "status_code": 0, - "status_msg": "" - } -} -``` - ### 使用 OpenAI 协议代理360智脑服务 **配置信息** @@ -970,103 +383,6 @@ provider: "*": "360gpt-pro" ``` -**请求示例** - -```json -{ - "model": "gpt-4o", - "messages": [ - { - "role": "system", - "content": "你是一个专业的开发人员!" - }, - { - "role": "user", - "content": "你好,你是谁?" - } - ] -} -``` - -**响应示例** - -```json -{ - "choices": [ - { - "message": { - "role": "assistant", - "content": "你好,我是360智脑,一个大型语言模型。我可以帮助回答各种问题、提供信息、进行对话等。有什么可以帮助你的吗?" - }, - "finish_reason": "", - "index": 0 - } - ], - "created": 1724257207, - "id": "5e5c94a2-d989-40b5-9965-5b971db941fe", - "model": "360gpt-turbo", - "object": "", - "usage": { - "completion_tokens": 33, - "prompt_tokens": 24, - "total_tokens": 57 - }, - "messages": [ - { - "role": "system", - "content": "你是一个专业的开发人员!" - }, - { - "role": "user", - "content": "你好,你是谁?" - } - ], - "context": null -} -``` - -**文本向量请求示例** - -URL: http://your-domain/v1/embeddings - -请求示例: - -```json -{ - "input":["你好"], - "model":"text-embedding-3-small" -} -``` - -响应示例: - -```json -{ - "data": [ - { - "embedding": [ - -0.011237, - -0.015433, - ..., - -0.028946, - -0.052778, - 0.003768, - -0.007917, - -0.042201 - ], - "index": 0, - "object": "" - } - ], - "model": "embedding_s1_v1.2", - "object": "", - "usage": { - "prompt_tokens": 2, - "total_tokens": 2 - } -} -``` - ### 使用 OpenAI 协议代理 Cloudflare Workers AI 服务 **配置信息** @@ -1081,43 +397,6 @@ provider: "*": "@cf/meta/llama-3-8b-instruct" ``` -**请求示例** - -```json -{ - "model": "gpt-3.5", - "max_tokens": 1024, - "messages": [ - { - "role": "user", - "content": "Who are you?" - } - ] -} -``` - -**响应示例** - -```json -{ - "id": "id-1720367803430", - "object": "chat.completion", - "created": 1720367803, - "model": "@cf/meta/llama-3-8b-instruct", - "choices": [ - { - "index": 0, - "message": { - "role": "assistant", - "content": "I am LLaMA, an AI assistant developed by Meta AI that can understand and respond to human input in a conversational manner. I'm not a human, but a computer program designed to simulate conversation and answer questions to the best of my knowledge. I can be used to generate text on a wide range of topics, from science and history to entertainment and culture.\n\nI'm a large language model, which means I've been trained on a massive dataset of text from the internet and can generate human-like responses. I can understand natural language and respond accordingly, making me suitable for tasks such as:\n\n* Answering questions on various topics\n* Generating text based on a given prompt\n* Translating text from one language to another\n* Summarizing long pieces of text\n* Creating chatbot dialogues\n\nI'm constantly learning and improving, so the more conversations I have with users like you, the better I'll become." - }, - "logprobs": null, - "finish_reason": "stop" - } - ] -} -``` - ### 使用 OpenAI 协议代理Spark服务 **配置信息** @@ -1133,50 +412,6 @@ provider: "*": "general" ``` -**请求示例** - -```json -{ - "model": "gpt-4o", - "messages": [ - { - "role": "system", - "content": "你是一名专业的开发人员!" - }, - { - "role": "user", - "content": "你好,你是谁?" - } - ], - "stream": false -} -``` - -**响应示例** - -```json -{ - "id": "cha000c23c6@dx190ef0b4b96b8f2532", - "choices": [ - { - "index": 0, - "message": { - "role": "assistant", - "content": "你好!我是一名专业的开发人员,擅长编程和解决技术问题。有什么我可以帮助你的吗?" - } - } - ], - "created": 1721997415, - "model": "generalv3.5", - "object": "chat.completion", - "usage": { - "prompt_tokens": 10, - "completion_tokens": 19, - "total_tokens": 29 - } -} -``` - ### 使用 OpenAI 协议代理 gemini 服务 **配置信息** @@ -1195,47 +430,6 @@ provider: "HARM_CATEGORY_DANGEROUS_CONTENT" :"BLOCK_NONE" ``` -**请求示例** - -```json -{ - "model": "gpt-3.5", - "messages": [ - { - "role": "user", - "content": "Who are you?" - } - ], - "stream": false -} -``` - -**响应示例** - -```json -{ - "id": "chatcmpl-b010867c-0d3f-40ba-95fd-4e8030551aeb", - "choices": [ - { - "index": 0, - "message": { - "role": "assistant", - "content": "I am a large multi-modal model, trained by Google. I am designed to provide information and answer questions to the best of my abilities." - }, - "finish_reason": "stop" - } - ], - "created": 1722756984, - "model": "gemini-pro", - "object": "chat.completion", - "usage": { - "prompt_tokens": 5, - "completion_tokens": 29, - "total_tokens": 34 - } -} -``` - ### 使用 OpenAI 协议代理 DeepL 文本翻译服务 **配置信息** @@ -1289,205 +483,3 @@ provider: } ``` -## 完整配置示例 - -### Kubernetes 示例 - -以下以使用 OpenAI 协议代理 Groq 服务为例,展示完整的插件配置示例。 - -```yaml -apiVersion: extensions.higress.io/v1alpha1 -kind: WasmPlugin -metadata: - name: ai-proxy-groq - namespace: higress-system -spec: - matchRules: - - config: - provider: - type: groq - apiTokens: - - "YOUR_API_TOKEN" - ingress: - - groq - url: oci://higress-registry.cn-hangzhou.cr.aliyuncs.com/plugins/ai-proxy:1.0.0 ---- -apiVersion: networking.k8s.io/v1 -kind: Ingress -metadata: - annotations: - higress.io/backend-protocol: HTTPS - higress.io/destination: groq.dns - higress.io/proxy-ssl-name: api.groq.com - higress.io/proxy-ssl-server-name: "on" - labels: - higress.io/resource-definer: higress - name: groq - namespace: higress-system -spec: - ingressClassName: higress - rules: - - host: - http: - paths: - - backend: - resource: - apiGroup: networking.higress.io - kind: McpBridge - name: default - path: / - pathType: Prefix ---- -apiVersion: networking.higress.io/v1 -kind: McpBridge -metadata: - name: default - namespace: higress-system -spec: - registries: - - domain: api.groq.com - name: groq - port: 443 - type: dns -``` - -访问示例: - -```bash -curl "http:///v1/chat/completions" -H "Content-Type: application/json" -d '{ - "model": "llama3-8b-8192", - "messages": [ - { - "role": "user", - "content": "你好,你是谁?" - } - ] -}' -``` - -### Docker-Compose 示例 - -`docker-compose.yml` 配置文件: - -```yaml -version: '3.7' -services: - envoy: - image: higress-registry.cn-hangzhou.cr.aliyuncs.com/higress/envoy:1.20 - entrypoint: /usr/local/bin/envoy - # 开启了 debug 级别日志方便调试 - command: -c /etc/envoy/envoy.yaml --component-log-level wasm:debug - networks: - - higress-net - ports: - - "10000:10000" - volumes: - - ./envoy.yaml:/etc/envoy/envoy.yaml - - ./plugin.wasm:/etc/envoy/plugin.wasm -networks: - higress-net: {} -``` - -`envoy.yaml` 配置文件: - -```yaml -admin: - address: - socket_address: - protocol: TCP - address: 0.0.0.0 - port_value: 9901 -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 - scheme_header_transformation: - scheme_to_overwrite: https - stat_prefix: ingress_http - # Output envoy logs to stdout - access_log: - - name: envoy.access_loggers.stdout - typed_config: - "@type": type.googleapis.com/envoy.extensions.access_loggers.stream.v3.StdoutAccessLog - # Modify as required - route_config: - name: local_route - virtual_hosts: - - name: local_service - domains: [ "*" ] - routes: - - match: - prefix: "/" - route: - cluster: claude - timeout: 300s - http_filters: - - name: claude - 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: claude - vm_config: - runtime: envoy.wasm.runtime.v8 - code: - local: - filename: /etc/envoy/plugin.wasm - configuration: - "@type": "type.googleapis.com/google.protobuf.StringValue" - value: | # 插件配置 - { - "provider": { - "type": "claude", - "apiTokens": [ - "YOUR_API_TOKEN" - ] - } - } - - name: envoy.filters.http.router - clusters: - - name: claude - connect_timeout: 30s - type: LOGICAL_DNS - dns_lookup_family: V4_ONLY - lb_policy: ROUND_ROBIN - load_assignment: - cluster_name: claude - endpoints: - - lb_endpoints: - - endpoint: - address: - socket_address: - address: api.anthropic.com # API 服务地址 - port_value: 443 - transport_socket: - name: envoy.transport_sockets.tls - typed_config: - "@type": type.googleapis.com/envoy.extensions.transport_sockets.tls.v3.UpstreamTlsContext - "sni": "api.anthropic.com" -``` - -访问示例: - -```bash -curl "http://localhost:10000/v1/chat/completions" -H "Content-Type: application/json" -d '{ - "model": "claude-3-opus-20240229", - "max_tokens": 1024, - "messages": [ - { - "role": "user", - "content": "你好,你是谁?" - } - ] -}' -``` diff --git a/backend/sdk/src/main/resources/plugins/ai-proxy/README_EN.md b/backend/sdk/src/main/resources/plugins/ai-proxy/README_EN.md index 5368782c..27c66bfd 100644 --- a/backend/sdk/src/main/resources/plugins/ai-proxy/README_EN.md +++ b/backend/sdk/src/main/resources/plugins/ai-proxy/README_EN.md @@ -1,318 +1,203 @@ --- title: AI Proxy keywords: [AI Gateway, AI Proxy] -description: Reference for configuring the AI Proxy plugin +description: AI Proxy plugin configuration reference --- - ## Function Description - -The `AI Proxy` plugin implements AI proxy functionality based on the OpenAI API contract. It currently supports AI service providers such as OpenAI, Azure OpenAI, Moonshot, and Qwen. +`AI Proxy` plugin implements AI proxy functionality based on OpenAI API contracts. It currently supports AI service providers such as OpenAI, Azure OpenAI, Moonshot, and Qwen. > **Note:** +> When the request path suffix matches `/v1/chat/completions`, corresponding to text generation scenarios, the request body will be parsed using OpenAI's text generation protocol and then converted to the corresponding LLM vendor's text generation protocol. +> +> When the request path suffix matches `/v1/embeddings`, corresponding to text vector scenarios, the request body will be parsed using OpenAI's text vector protocol and then converted to the corresponding LLM vendor's text vector protocol. -> When the request path suffix matches `/v1/chat/completions`, it corresponds to text-to-text scenarios. The request body will be parsed using OpenAI's text-to-text protocol and then converted to the corresponding LLM vendor's text-to-text protocol. - -> When the request path suffix matches `/v1/embeddings`, it corresponds to text vector scenarios. The request body will be parsed using OpenAI's text vector protocol and then converted to the corresponding LLM vendor's text vector protocol. - -## Execution Properties -Plugin execution phase: `Default Phase` +## Running Attributes +Plugin execution phase: `Default phase` Plugin execution priority: `100` - ## Configuration Fields - ### Basic Configuration - -| Name | Data Type | Requirement | Default | Description | -|------------|--------|------|-----|------------------| -| `provider` | object | Required | - | Configures information for the target AI service provider | - -**Details for the `provider` configuration fields:** - -| Name | Data Type | Requirement | Default | Description | -| -------------- | --------------- | -------- | ------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `type` | string | Required | - | Name of the AI service provider | -| `apiTokens` | array of string | Optional | - | Tokens used for authentication when accessing AI services. If multiple tokens are configured, the plugin randomly selects one for each request. Some service providers only support configuring a single token. | -| `timeout` | number | Optional | - | Timeout for accessing AI services, in milliseconds. The default value is 120000, which equals 2 minutes. | -| `modelMapping` | map of string | Optional | - | Mapping table for AI models, used to map model names in requests to names supported by the service provider.
1. Supports prefix matching. For example, "gpt-3-*" matches all model names starting with “gpt-3-”;
2. Supports using "*" as a key for a general fallback mapping;
3. If the mapped target name is an empty string "", the original model name is preserved. | -| `protocol` | string | Optional | - | API contract provided by the plugin. Currently supports the following values: openai (default, uses OpenAI's interface contract), original (uses the raw interface contract of the target service provider) | -| `context` | object | Optional | - | Configuration for AI conversation context information | -| `customSettings` | array of customSetting | Optional | - | Specifies overrides or fills parameters for AI requests | - -**Details for the `context` configuration fields:** - -| Name | Data Type | Requirement | Default | Description | -|---------------|--------|------|-----|----------------------------------| -| `fileUrl` | string | Required | - | File URL to save AI conversation context. Only supports file content of plain text type | -| `serviceName` | string | Required | - | Full name of the Higress backend service corresponding to the URL | -| `servicePort` | number | Required | - | Port for accessing the Higress backend service corresponding to the URL | - -**Details for the `customSettings` configuration fields:** - -| Name | Data Type | Requirement | Default | Description | -| ----------- | --------------------- | -------- | ------ | ---------------------------------------------------------------------------------------------------------------------------- | -| `name` | string | Required | - | Name of the parameter to set, e.g., `max_tokens` | -| `value` | string/int/float/bool | Required | - | Value of the parameter to set, e.g., 0 | -| `mode` | string | Optional | "auto" | Mode for setting the parameter, can be set to "auto" or "raw"; if "auto", the parameter name will be automatically rewritten based on the protocol; if "raw", no rewriting or restriction checks will be applied | -| `overwrite` | bool | Optional | true | If false, the parameter is only filled if the user has not set it; otherwise, it directly overrides the user's existing parameter settings | - -The `custom-setting` adheres to the following table, replacing the corresponding field based on `name` and protocol. Users need to fill in values from the `settingName` column that exists in the table. For instance, if a user sets `name` to `max_tokens`, in the openai protocol, it replaces `max_tokens`; for gemini, it replaces `maxOutputTokens`. `"none"` indicates that the protocol does not support this parameter. If `name` is not in this table or the corresponding protocol does not support the parameter, and "raw" mode is not set, the configuration will not take effect. - -| settingName | openai | baidu | spark | qwen | gemini | hunyuan | claude | minimax | -| ----------- | ----------- | ----------------- | ----------- | ----------- | --------------- | ----------- | ----------- | ------------------ | -| max_tokens | max_tokens | max_output_tokens | max_tokens | max_tokens | maxOutputTokens | none | max_tokens | tokens_to_generate | -| temperature | temperature | temperature | temperature | temperature | temperature | Temperature | temperature | temperature | -| top_p | top_p | top_p | none | top_p | topP | TopP | top_p | top_p | -| top_k | none | none | top_k | none | topK | none | top_k | none | -| seed | seed | none | none | seed | none | none | none | none | - -If raw mode is enabled, `custom-setting` will directly alter the JSON content using the input `name` and `value`, without any restrictions or modifications to the parameter names. -For most protocols, `custom-setting` modifies or fills parameters at the root path of the JSON content. For the `qwen` protocol, ai-proxy configures under the `parameters` subpath. For the `gemini` protocol, it configures under the `generation_config` subpath. - -### Provider-Specific Configurations - +| Name | Data Type | Requirement | Default Value | Description | +|--------------|-------------|-------------|---------------|----------------------------------------| +| `provider` | object | Required | - | Information about the target AI service provider | + +The description of fields in `provider` is as follows: + +| Name | Data Type | Requirement | Default Value | Description | +|-------------------|------------------|-------------|---------------|-----------------------------------------------------------------------------------------------------------| +| `type` | string | Required | - | Name of the AI service provider | +| `apiTokens` | array of string | Optional | - | Tokens for authentication when accessing the AI service. If multiple tokens are provided, the plugin will randomly choose one when making requests. Some service providers only support one token configuration. | +| `timeout` | number | Optional | - | Timeout for accessing the AI service, in milliseconds. The default value is 120000, which is 2 minutes. | +| `modelMapping` | map of string | Optional | - | AI model mapping table for mapping model names in requests to supported model names by the service provider.
1. Supports prefix matching. For example, "gpt-3-*" matches all models whose names start with "gpt-3-";
2. Supports using "*" as a key to configure a general fallback mapping;
3. If the target name in the mapping is an empty string "", it means to retain the original model name. | +| `protocol` | string | Optional | - | The API interface contract provided by the plugin. Currently supports the following values: openai (default, uses OpenAI's interface contract), original (uses the original interface contract of the target service provider) | +| `context` | object | Optional | - | Configuration for AI conversation context information | +| `customSettings` | array of customSetting | Optional | - | Specify override or fill parameters for AI requests | + +The description of fields in `context` is as follows: + +| Name | Data Type | Requirement | Default Value | Description | +|-----------------|-------------|-------------|---------------|--------------------------------------------------| +| `fileUrl` | string | Required | - | URL of the file that stores AI conversation context. Only pure text file content is supported. | +| `serviceName` | string | Required | - | The complete name of the Higress backend service corresponding to the URL. | +| `servicePort` | number | Required | - | The access port of the Higress backend service corresponding to the URL. | + +The description of fields in `customSettings` is as follows: + +| Name | Data Type | Requirement | Default Value | Description | +|-------------|-------------------------|-------------|---------------|--------------------------------------------------------------------------| +| `name` | string | Required | - | Name of the parameter to set, e.g., `max_tokens` | +| `value` | string/int/float/bool | Required | - | Value for the parameter to set, e.g., 0 | +| `mode` | string | Optional | "auto" | Mode for parameter settings, can be set to "auto" or "raw". If "auto", parameter names will be automatically rewritten based on the protocol; if "raw", no rewriting or validation checks will be done. | +| `overwrite` | bool | Optional | true | If false, the parameter will only be filled if the user hasn't set it; otherwise, it will overwrite the user's original parameter settings. | + +Custom settings will follow the table below to replace corresponding fields based on `name` and protocol. Users need to fill in values that exist in the `settingName` column of the table. For example, if the user sets `name` to `max_tokens`, it will be replaced by `max_tokens` in the OpenAI protocol, and by `maxOutputTokens` in Gemini. `none` indicates that the protocol does not support this parameter. If `name` is not in this table or the corresponding protocol does not support this parameter, and if raw mode is not set, the configuration will not take effect. + +| settingName | openai | baidu | spark | qwen | gemini | hunyuan | claude | minimax | +|--------------|-------------|-------------------|-------------|-------------|------------------|-------------|-------------|--------------------| +| max_tokens | max_tokens | max_output_tokens | max_tokens | max_tokens | maxOutputTokens | none | max_tokens | tokens_to_generate | +| temperature | temperature | temperature | temperature | temperature | temperature | Temperature | temperature | temperature | +| top_p | top_p | top_p | none | top_p | topP | TopP | top_p | top_p | +| top_k | none | none | top_k | none | topK | none | top_k | none | +| seed | seed | none | none | seed | none | none | none | none | + +If raw mode is enabled, custom settings will directly use the input `name` and `value` to change the JSON content of the request without any restrictions or modifications to the parameter names. + +For most protocols, custom settings will modify or fill parameters at the root path of the JSON content. For the `qwen` protocol, the ai-proxy will configure under the `parameters` sub-path in JSON. For the `gemini` protocol, it will be configured under the `generation_config` sub-path. + +### Provider-Specific Configuration #### OpenAI +The `type` corresponding to OpenAI is `openai`. Its specific configuration fields are as follows: -For OpenAI, the corresponding `type` is `openai`. Its unique configuration fields include: - -| Name | Data Type | Requirement | Default | Description | -|-------------------|----------|----------|--------|-------------------------------------------------------------------------------| -| `openaiCustomUrl` | string | Optional | - | Custom backend URL based on the OpenAI protocol, e.g., www.example.com/myai/v1/chat/completions | -| `responseJsonSchema` | object | Optional | - | Predefined Json Schema that OpenAI responses must adhere to; note that currently only a few specific models support this usage| +| Name | Data Type | Requirement | Default Value | Description | +|------------------------|-----------|-------------|---------------|------------------------------------------------------------------------------------| +| `openaiCustomUrl` | string | Optional | - | Custom backend URL based on OpenAI protocol, e.g., www.example.com/myai/v1/chat/completions | +| `responseJsonSchema` | object | Optional | - | Predefined Json Schema that OpenAI responses must satisfy, currently only supported by specific models. | #### Azure OpenAI +The `type` corresponding to Azure OpenAI is `azure`. Its specific configuration fields are as follows: -For Azure OpenAI, the corresponding `type` is `azure`. Its unique configuration field is: - -| Name | Data Type | Filling Requirements | Default Value | Description | -|---------------------|-------------|----------------------|---------------|---------------------------------------------------------------------------------------------------------------| -| `azureServiceUrl` | string | Required | - | The URL of the Azure OpenAI service, must include the `api-version` query parameter. | - -**Note:** Azure OpenAI only supports configuring one API Token. +| Name | Data Type | Requirement | Default Value | Description | +|------------------------|-----------|-------------|---------------|-----------------------------------------------------------------------| +| `azureServiceUrl` | string | Required | - | URL of Azure OpenAI service, must include `api-version` query parameter. | +**Note:** Azure OpenAI only supports the configuration of one API Token. #### Moonshot +The `type` corresponding to Moonshot is `moonshot`. Its specific configuration fields are as follows: -For Moonshot, the corresponding `type` is `moonshot`. Its unique configuration field is: - -| Name | Data Type | Filling Requirements | Default Value | Description | -|-------------------|-------------|----------------------|---------------|-----------------------------------------------------------------------------------------------------------------| -| `moonshotFileId` | string | Optional | - | The file ID uploaded via the file interface to Moonshot, whose content will be used as context for AI conversations. Cannot be configured with the `context` field. | - -#### Qwen (Tongyi Qwen) +| Name | Data Type | Requirement | Default Value | Description | +|------------------------|-----------|-------------|---------------|-----------------------------------------------------------------------| +| `moonshotFileId` | string | Optional | - | File ID uploaded to Moonshot via the file interface, its content will be used as the context for AI conversation. Cannot be configured simultaneously with the `context` field. | -For Qwen (Tongyi Qwen), the corresponding `type` is `qwen`. Its unique configuration fields are: +#### Qwen +The `type` corresponding to Qwen is `qwen`. Its specific configuration fields are as follows: -| Name | Data Type | Filling Requirements | Default Value | Description | -|--------------------|-----------------|----------------------|---------------|------------------------------------------------------------------------------------------------------------------------| -| `qwenEnableSearch` | boolean | Optional | - | Whether to enable the built-in Internet search function provided by Qwen. | -| `qwenFileIds` | array of string | Optional | - | The file IDs uploaded via the Dashscope file interface, whose content will be used as context for AI conversations. Cannot be configured with the `context` field. | +| Name | Data Type | Requirement | Default Value | Description | +|------------------------|----------------------|-------------|---------------|----------------------------------------------------------------------| +| `qwenEnableSearch` | boolean | Optional | - | Whether to enable the built-in internet search functionality of Qwen. | +| `qwenFileIds` | array of string | Optional | - | File IDs uploaded to Dashscope via the file interface, its contents will be used as the context for AI conversation. Cannot be configured simultaneously with the `context` field. | #### Baichuan AI +The `type` corresponding to Baichuan AI is `baichuan`. It has no specific configuration fields. -For Baichuan AI, the corresponding `type` is `baichuan`. It has no unique configuration fields. - -#### Yi (Zero One Universe) - -For Yi (Zero One Universe), the corresponding `type` is `yi`. It has no unique configuration fields. +#### Yi +The `type` corresponding to Yi is `yi`. It has no specific configuration fields. #### Zhipu AI - -For Zhipu AI, the corresponding `type` is `zhipuai`. It has no unique configuration fields. +The `type` corresponding to Zhipu AI is `zhipuai`. It has no specific configuration fields. #### DeepSeek - -For DeepSeek, the corresponding `type` is `deepseek`. It has no unique configuration fields. +The `type` corresponding to DeepSeek is `deepseek`. It has no specific configuration fields. #### Groq +The `type` corresponding to Groq is `groq`. It has no specific configuration fields. -For Groq, the corresponding `type` is `groq`. It has no unique configuration fields. - -#### ERNIE Bot - -For ERNIE Bot, the corresponding `type` is `baidu`. It has no unique configuration fields. - -### 360 Brain +#### Baidu +The `type` corresponding to Baidu is `baidu`. It has no specific configuration fields. -For 360 Brain, the corresponding `type` is `ai360`. It has no unique configuration fields. +#### AI360 +The `type` corresponding to AI360 is `ai360`. It has no specific configuration fields. -### Mistral +#### Mistral +The `type` corresponding to Mistral is `mistral`. It has no specific configuration fields. -For Mistral, the corresponding `type` is `mistral`. It has no unique configuration fields. +#### MiniMax +The `type` corresponding to MiniMax is `minimax`. Its specific configuration fields are as follows: -#### Minimax - -For Minimax, the corresponding `type` is `minimax`. Its unique configuration field is: - -| Name | Data Type | Filling Requirements | Default Value | Description | -| ---------------- | -------- | --------------------- |---------------|------------------------------------------------------------------------------------------------------------| -| `minimaxGroupId` | string | Required when using models `abab6.5-chat`, `abab6.5s-chat`, `abab5.5s-chat`, `abab5.5-chat` | - | When using models `abab6.5-chat`, `abab6.5s-chat`, `abab5.5s-chat`, `abab5.5-chat`, Minimax uses ChatCompletion Pro and requires setting the groupID. | +| Name | Data Type | Requirement | Default Value | Description | +|------------------------|-----------|-----------------------------------------|---------------|--------------------------------------------------------------------| +| `minimaxGroupId` | string | Required when using `abab6.5-chat`, `abab6.5s-chat`, `abab5.5s-chat`, or `abab5.5-chat` models | - | When using these models, ChatCompletion Pro will be used, and `groupID` needs to be set. | #### Anthropic Claude +The `type` corresponding to Anthropic Claude is `claude`. Its specific configuration fields are as follows: -For Anthropic Claude, the corresponding `type` is `claude`. Its unique configuration field is: - -| Name | Data Type | Filling Requirements | Default Value | Description | -|------------|-------------|----------------------|---------------|---------------------------------------------------------------------------------------------------------------| -| `claudeVersion` | string | Optional | - | The version of the Claude service's API, default is 2023-06-01. | +| Name | Data Type | Requirement | Default Value | Description | +|------------------------|-----------|-------------|---------------|----------------------------------------------------------------------| +| `claudeVersion` | string | Optional | - | The API version for Claude service, defaults to 2023-06-01 | #### Ollama +The `type` corresponding to Ollama is `ollama`. Its specific configuration fields are as follows: -For Ollama, the corresponding `type` is `ollama`. Its unique configuration field is: - -| Name | Data Type | Filling Requirements | Default Value | Description | -|-------------------|-------------|----------------------|---------------|---------------------------------------------------------------------------------------------------------| -| `ollamaServerHost` | string | Required | - | The host address of the Ollama server. | -| `ollamaServerPort` | number | Required | - | The port number of the Ollama server, defaults to 11434. | +| Name | Data Type | Requirement | Default Value | Description | +|------------------------|-----------|-------------|---------------|---------------------------------------------------------------------| +| `ollamaServerHost` | string | Required | - | Host address for the Ollama server | +| `ollamaServerPort` | number | Required | - | Port number for the Ollama server, defaults to 11434 | #### Hunyuan +The `type` corresponding to Hunyuan is `hunyuan`. Its specific configuration fields are as follows: -For Hunyuan, the corresponding `type` is `hunyuan`. Its unique configuration fields are: - -| Name | Data Type | Filling Requirements | Default Value | Description | -|-------------------|-------------|----------------------|---------------|---------------------------------------------------------------------------------------------------------| -| `hunyuanAuthId` | string | Required | - | Hunyuan authentication ID for version 3 authentication. | -| `hunyuanAuthKey` | string | Required | - | Hunyuan authentication key for version 3 authentication. | +| Name | Data Type | Requirement | Default Value | Description | +|------------------------|-----------|-------------|---------------|---------------------------------------------------------------------| +| `hunyuanAuthId` | string | Required | - | ID used for Hunyuan authentication with version v3 | +| `hunyuanAuthKey` | string | Required | - | Key used for Hunyuan authentication with version v3 | #### Stepfun - -For Stepfun, the corresponding `type` is `stepfun`. It has no unique configuration fields. +The `type` corresponding to Stepfun is `stepfun`. It has no specific configuration fields. #### Cloudflare Workers AI +The `type` corresponding to Cloudflare Workers AI is `cloudflare`. Its specific configuration fields are as follows: -For Cloudflare Workers AI, the corresponding `type` is `cloudflare`. Its unique configuration field is: - -| Name | Data Type | Filling Requirements | Default Value | Description | -|-------------------|-------------|----------------------|---------------|---------------------------------------------------------------------------------------------------------| -| `cloudflareAccountId` | string | Required | - | [Cloudflare Account ID](https://developers.cloudflare.com/workers-ai/get-started/rest-api/#1-get-api-token-and-account-id). | +| Name | Data Type | Requirement | Default Value | Description | +|------------------------|-----------|-------------|---------------|---------------------------------------------------------------------| +| `cloudflareAccountId` | string | Required | - | [Cloudflare Account ID](https://developers.cloudflare.com/workers-ai/get-started/rest-api/#1-get-api-token-and-account-id) | #### Spark +The `type` corresponding to Spark is `spark`. It has no specific configuration fields. -For Spark, the corresponding `type` is `spark`. It has no unique configuration fields. - -The `apiTokens` field value for Xunfei Spark (Xunfei Star) is `APIKey:APISecret`. That is, enter your own APIKey and APISecret, separated by `:`. +The `apiTokens` field value for iFlytek’s Spark cognitive large model is `APIKey:APISecret`. That is, fill in your own APIKey and APISecret, separated by `:`. #### Gemini +The `type` corresponding to Gemini is `gemini`. Its specific configuration fields are as follows: -For Gemini, the corresponding `type` is `gemini`. Its unique configuration field is: +| Name | Data Type | Requirement | Default Value | Description | +|------------------------|-----------|-------------|---------------|------------------------------------------------------------------| +| `geminiSafetySetting` | map of string | Optional | - | Gemini AI content filtering and safety level settings. Refer to [Safety settings](https://ai.google.dev/gemini-api/docs/safety-settings). | -| Name | Data Type | Filling Requirements | Default Value | Description | -|---------------------|----------|----------------------|---------------|---------------------------------------------------------------------------------------------------------| -| `geminiSafetySetting` | map of string | Optional | - | Gemini AI content filtering and safety level settings. Refer to [Safety settings](https://ai.google.dev/gemini-api/docs/safety-settings). | +#### DeepL +The `type` corresponding to DeepL is `deepl`. Its specific configuration fields are as follows: -### DeepL +| Name | Data Type | Requirement | Default Value | Description | +|------------------------|-----------|-------------|---------------|--------------------------------------------------| +| `targetLang` | string | Required | - | Target language required by DeepL translation service. | -For DeepL, the corresponding `type` is `deepl`. Its unique configuration field is: - -| Name | Data Type | Requirement | Default | Description | -| ------------ | --------- | ----------- | ------- | ------------------------------------ | -| `targetLang` | string | Required | - | The target language required by the DeepL translation service | +#### Cohere +The `type` corresponding to Cohere is `cohere`. It has no specific configuration fields. ## Usage Examples - -### Using OpenAI Protocol Proxy for Azure OpenAI Service - -Using the basic Azure OpenAI service without configuring any context. +### Using OpenAI Protocol to Proxy Azure OpenAI Service +Using the most basic Azure OpenAI service with no context configured. **Configuration Information** - ```yaml provider: type: azure apiTokens: - "YOUR_AZURE_OPENAI_API_TOKEN" - azureServiceUrl: "https://YOUR_RESOURCE_NAME.openai.azure.com/openai/deployments/YOUR_DEPLOYMENT_NAME/chat/completions?api-version=2024-02-15-preview", -``` - -**Request Example** - -```json -{ - "model": "gpt-3", - "messages": [ - { - "role": "user", - "content": "Hello, who are you?" - } - ], - "temperature": 0.3 -} -``` - -**Response Example** - -```json -{ - "choices": [ - { - "content_filter_results": { - "hate": { - "filtered": false, - "severity": "safe" - }, - "self_harm": { - "filtered": false, - "severity": "safe" - }, - "sexual": { - "filtered": false, - "severity": "safe" - }, - "violence": { - "filtered": false, - "severity": "safe" - } - }, - "finish_reason": "stop", - "index": 0, - "logprobs": null, - "message": { - "content": "Hello! I am an AI assistant, here to answer your questions and provide assistance. Is there anything I can help you with?", - "role": "assistant" - } - } - ], - "created": 1714807624, - "id": "chatcmpl-abcdefg1234567890", - "model": "gpt-35-turbo-16k", - "object": "chat.completion", - "prompt_filter_results": [ - { - "prompt_index": 0, - "content_filter_results": { - "hate": { - "filtered": false, - "severity": "safe" - }, - "self_harm": { - "filtered": false, - "severity": "safe" - }, - "sexual": { - "filtered": false, - "severity": "safe" - }, - "violence": { - "filtered": false, - "severity": "safe" - } - } - } - ], - "system_fingerprint": null, - "usage": { - "completion_tokens": 40, - "prompt_tokens": 15, - "total_tokens": 55 - } -} + azureServiceUrl: "https://YOUR_RESOURCE_NAME.openai.azure.com/openai/deployments/YOUR_DEPLOYMENT_NAME/chat/completions?api-version=2024-02-15-preview" ``` - -### Using OpenAI Protocol Proxy for Qwen Service - -Using Qwen service and configuring the mapping relationship between OpenAI large models and Qwen models. +### Using OpenAI Protocol to Proxy Qwen Service +Using Qwen service with a model mapping from OpenAI large models to Qwen. **Configuration Information** - ```yaml provider: type: qwen @@ -327,392 +212,49 @@ provider: 'text-embedding-v1': 'text-embedding-v1' '*': "qwen-turbo" ``` - -**AI Conversation Request Example** - -URL: http://your-domain/v1/chat/completions - -Request Example: - -```json -{ - "model": "gpt-3", - "messages": [ - { - "role": "user", - "content": "Hello, who are you?" - } - ], - "temperature": 0.3 -} -``` - -Response Example: - -```json -{ - "id": "c2518bd3-0f46-97d1-be34-bb5777cb3108", - "choices": [ - { - "index": 0, - "message": { - "role": "assistant", - "content": "I am Qwen, an AI assistant developed by Alibaba Cloud. I can answer various questions, provide information, and engage in conversations with users. How can I assist you?" - }, - "finish_reason": "stop" - } - ], - "created": 1715175072, - "model": "qwen-turbo", - "object": "chat.completion", - "usage": { - "prompt_tokens": 24, - "completion_tokens": 33, - "total_tokens": 57 - } -} -``` - -**Multimodal Model API Request Example (Applicable to `qwen-vl-plus` and `qwen-vl-max` Models)** - -URL: http://your-domain/v1/chat/completions - -Request Example: - -```json -{ - "model": "gpt-4o", - "messages": [ - { - "role": "user", - "content": [ - { - "type": "image_url", - "image_url": { - "url": "https://dashscope.oss-cn-beijing.aliyuncs.com/images/dog_and_girl.jpeg" - } - }, - { - "type": "text", - "text": "Where is this picture from?" - } - ] - } - ], - "temperature": 0.3 -} -``` - -Response Example: - -```json -{ - "id": "17c5955d-af9c-9f28-bbde-293a9c9a3515", - "choices": [ - { - "index": 0, - "message": { - "role": "assistant", - "content": [ - { - "text": "This photo depicts a woman and a dog on a beach. As I cannot access specific geographical information, I cannot pinpoint the exact location of this beach. However, visually, it appears to be a sandy coastline along a coastal area with waves breaking on the shore. Such scenes can be found in many beautiful seaside locations worldwide. If you need more precise information, please provide additional context or descriptive details." - } - ] - }, - "finish_reason": "stop" - } - ], - "created": 1723949230, - "model": "qwen-vl-plus", - "object": "chat.completion", - "usage": { - "prompt_tokens": 1279, - "completion_tokens": 78 - } -} -``` - -**Text Embedding Request Example** - -URL: http://your-domain/v1/embeddings - -Request Example: - -```json -{ - "model": "text-embedding-v1", - "input": "Hello" -} -``` - -Response Example: - -```json -{ - "object": "list", - "data": [ - { - "object": "embedding", - "index": 0, - "embedding": [ - -1.0437825918197632, - 5.208984375, - 3.0483806133270264, - -1.7897135019302368, - -2.0107421875, - ..., - 0.8125, - -1.1759847402572632, - 0.8174641728401184, - 1.0432943105697632, - -0.5885213017463684 - ] - } - ], - "model": "text-embedding-v1", - "usage": { - "prompt_tokens": 1, - "total_tokens": 1 - } -} -``` - -### Using Qwen Service with Pure Text Context Information - -Using Qwen service while configuring pure text context information. - +### Using original protocol to Proxy Baichuan AI Proxy application **Configuration Information** - ```yaml provider: type: qwen apiTokens: - - "YOUR_QWEN_API_TOKEN" - modelMapping: - "*": "qwen-turbo" - context: - - fileUrl: "http://file.default.svc.cluster.local/ai/context.txt", - serviceName: "file.dns", - servicePort: 80 + - "YOUR_DASHSCOPE_API_TOKEN" + protocol: original ``` - -**Request Example** - -```json -{ - "model": "gpt-3", - "messages": [ - { - "role": "user", - "content": "Please summarize the content" - } - ], - "temperature": 0.3 -} -``` - -**Response Example** - -```json -{ - "id": "cmpl-77861a17681f4987ab8270dbf8001936", - "object": "chat.completion", - "created": 9756990, - "model": "moonshot-v1-128k", - "choices": [ - { - "index": 0, - "message": { - "role": "assistant", - "content": "The content of this document is about..." - }, - "finish_reason": "stop" - } - ], - "usage": { - "prompt_tokens": 20181, - "completion_tokens": 439, - "total_tokens": 20620 - } -} -``` - -### Using Qwen Service with Native File Context - -Uploading files to Qwen in advance to use them as context when utilizing its AI service. - +### Using OpenAI Protocol to Proxy Doubao Large Model Service **Configuration Information** - ```yaml provider: - type: qwen + type: doubao apiTokens: - - "YOUR_QWEN_API_TOKEN" + - "YOUR_DOUBAO_API_KEY" modelMapping: - "*": "qwen-long" # Qwen's file context can only be used in the qwen-long model - qwenFileIds: - - "file-fe-xxx" - - "file-fe-yyy" -``` - -**Request Example** - -```json -{ - "model": "gpt-4-turbo", - "messages": [ - { - "role": "user", - "content": "Please summarize the content" - } - ], - "temperature": 0.3 -} -``` - -**Response Example** - -```json -{ - "output": { - "choices": [ - { - "finish_reason": "stop", - "message": { - "role": "assistant", - "content": "You uploaded two files, `context.txt` and `context_2.txt`, which seem to contain information about..." - } - } - ] - }, - "usage": { - "total_tokens": 2023, - "output_tokens": 530, - "input_tokens": 1493 - }, - "request_id": "187e99ba-5b64-9ffe-8f69-01dafbaf6ed7" -} + '*': YOUR_DOUBAO_ENDPOINT + timeout: 1200000 ``` -### Utilizing Moonshot with its Native File Context - -Upload files to Moonshot in advance and use its AI services based on file content. +### Using Moonshot with its native file context +Pre-upload a file to Moonshot to use its content as context for its AI service. **Configuration Information** - ```yaml provider: type: moonshot apiTokens: - "YOUR_MOONSHOT_API_TOKEN" - moonshotFileId: "YOUR_MOONSHOT_FILE_ID", + moonshotFileId: "YOUR_MOONSHOT_FILE_ID" modelMapping: '*': "moonshot-v1-32k" ``` - -**Example Request** - -```json -{ - "model": "gpt-4-turbo", - "messages": [ - { - "role": "user", - "content": "Please summarize the content" - } - ], - "temperature": 0.3 -} -``` - -**Example Response** - -```json -{ - "id": "cmpl-e5ca873642ca4f5d8b178c1742f9a8e8", - "object": "chat.completion", - "created": 1872961, - "model": "moonshot-v1-128k", - "choices": [ - { - "index": 0, - "message": { - "role": "assistant", - "content": "The content of the text is about a payment platform named ‘xxxx’..." - }, - "finish_reason": "stop" - } - ], - "usage": { - "prompt_tokens": 11, - "completion_tokens": 498, - "total_tokens": 509 - } -} -``` - -### Using OpenAI Protocol Proxy for Groq Service - +### Using OpenAI Protocol to Proxy Groq Service **Configuration Information** - ```yaml provider: type: groq apiTokens: - "YOUR_GROQ_API_TOKEN" ``` - -**Example Request** - -```json -{ - "model": "llama3-8b-8192", - "messages": [ - { - "role": "user", - "content": "Hello, who are you?" - } - ] -} -``` - -**Example Response** - -```json -{ - "id": "chatcmpl-26733989-6c52-4056-b7a9-5da791bd7102", - "object": "chat.completion", - "created": 1715917967, - "model": "llama3-8b-8192", - "choices": [ - { - "index": 0, - "message": { - "role": "assistant", - "content": "Ni Hao! (That's \"hello\" in Chinese!)\n\nI am LLaMA, an AI assistant developed by Meta AI that can understand and respond to human input in a conversational manner. I'm not a human, but a computer program designed to simulate conversations and answer questions to the best of my ability. I'm happy to chat with you in Chinese or help with any questions or topics you'd like to discuss!" - }, - "logprobs": null, - "finish_reason": "stop" - } - ], - "usage": { - "prompt_tokens": 16, - "prompt_time": 0.005, - "completion_tokens": 89, - "completion_time": 0.104, - "total_tokens": 105, - "total_time": 0.109 - }, - "system_fingerprint": "fp_dadc9d6142", - "x_groq": { - "id": "req_01hy2awmcxfpwbq56qh6svm7qz" - } -} -``` - -### Using OpenAI Protocol Proxy for Claude Service - +### Using OpenAI Protocol to Proxy Claude Service **Configuration Information** - ```yaml provider: type: claude @@ -720,52 +262,8 @@ provider: - "YOUR_CLAUDE_API_TOKEN" version: "2023-06-01" ``` - -**Example Request** - -```json -{ - "model": "claude-3-opus-20240229", - "max_tokens": 1024, - "messages": [ - { - "role": "user", - "content": "Hello, who are you?" - } - ] -} -``` - -**Example Response** - -```json -{ - "id": "msg_01Jt3GzyjuzymnxmZERJguLK", - "choices": [ - { - "index": 0, - "message": { - "role": "assistant", - "content": "Hello, I am a conversation system developed by Anthropic, a company specializing in artificial intelligence. My name is Claude, a friendly and knowledgeable chatbot. Nice to meet you! I can engage in discussions on various topics, answer questions, provide suggestions, and assist you. I'll do my best to give you helpful responses. I hope we have a pleasant exchange!" - }, - "finish_reason": "stop" - } - ], - "created": 1717385918, - "model": "claude-3-opus-20240229", - "object": "chat.completion", - "usage": { - "prompt_tokens": 16, - "completion_tokens": 126, - "total_tokens": 142 - } -} -``` - -### Using OpenAI Protocol Proxy for Hunyuan Service - +### Using OpenAI Protocol to Proxy Hunyuan Service **Configuration Information** - ```yaml provider: type: "hunyuan" @@ -777,62 +275,8 @@ provider: modelMapping: "*": "hunyuan-lite" ``` - -**Example Request** - -Request script: - -```sh - -curl --location 'http:///v1/chat/completions' \ ---header 'Content-Type: application/json' \ ---data '{ - "model": "gpt-3", - "messages": [ - { - "role": "system", - "content": "You are a professional developer!" - }, - { - "role": "user", - "content": "Hello, who are you?" - } - ], - "temperature": 0.3, - "stream": false -}' -``` - -**Example Response** - -```json -{ - "id": "fd140c3e-0b69-4b19-849b-d354d32a6162", - "choices": [ - { - "index": 0, - "delta": { - "role": "assistant", - "content": "Hello! I am a professional developer." - }, - "finish_reason": "stop" - } - ], - "created": 1717493117, - "model": "hunyuan-lite", - "object": "chat.completion", - "usage": { - "prompt_tokens": 15, - "completion_tokens": 9, - "total_tokens": 24 - } -} -``` - -### Using OpenAI Protocol Proxy for ERNIE Bot Service - +### Using OpenAI Protocol to Proxy Baidu Wenxin Service **Configuration Information** - ```yaml provider: type: baidu @@ -842,52 +286,8 @@ provider: 'gpt-3': "ERNIE-4.0" '*': "ERNIE-4.0" ``` - -**Request Example** - -```json -{ - "model": "gpt-4-turbo", - "messages": [ - { - "role": "user", - "content": "Hello, who are you?" - } - ], - "stream": false -} -``` - -**Response Example** - -```json -{ - "id": "as-e90yfg1pk1", - "choices": [ - { - "index": 0, - "message": { - "role": "assistant", - "content": "Hello, I am ERNIE Bot. I can interact with people, answer questions, assist in creation, and efficiently provide information, knowledge, and inspiration." - }, - "finish_reason": "stop" - } - ], - "created": 1717251488, - "model": "ERNIE-4.0", - "object": "chat.completion", - "usage": { - "prompt_tokens": 4, - "completion_tokens": 33, - "total_tokens": 37 - } -} -``` - -### Using OpenAI Protocol Proxy for MiniMax Service - +### Using OpenAI Protocol to Proxy MiniMax Service **Configuration Information** - ```yaml provider: type: minimax @@ -899,58 +299,8 @@ provider: "*": "abab6.5g-chat" minimaxGroupId: "YOUR_MINIMAX_GROUP_ID" ``` - -**Request Example** - -```json -{ - "model": "gpt-4-turbo", - "messages": [ - { - "role": "user", - "content": "Hello, who are you?" - } - ], - "stream": false -} -``` - -**Response Example** - -```json -{ - "id": "02b2251f8c6c09d68c1743f07c72afd7", - "choices": [ - { - "finish_reason": "stop", - "index": 0, - "message": { - "content": "Hello! I am MM Intelligent Assistant, a large language model developed by MiniMax. I can help answer questions, provide information, and engage in conversations. How can I assist you?", - "role": "assistant" - } - } - ], - "created": 1717760544, - "model": "abab6.5s-chat", - "object": "chat.completion", - "usage": { - "total_tokens": 106 - }, - "input_sensitive": false, - "output_sensitive": false, - "input_sensitive_type": 0, - "output_sensitive_type": 0, - "base_resp": { - "status_code": 0, - "status_msg": "" - } -} -``` - -### Using OpenAI Protocol Proxy for 360 Brain Services - +### Using OpenAI Protocol to Proxy AI360 Service **Configuration Information** - ```yaml provider: type: ai360 @@ -963,108 +313,8 @@ provider: "text-embedding-3-small": "embedding_s1_v1.2" "*": "360gpt-pro" ``` - -**Request Example** - -```json -{ - "model": "gpt-4o", - "messages": [ - { - "role": "system", - "content": "You are a professional developer!" - }, - { - "role": "user", - "content": "Hello, who are you?" - } - ] -} -``` - -**Response Example** - -```json -{ - "choices": [ - { - "message": { - "role": "assistant", - "content": "Hello, I am 360 Brain, a large language model. I can assist with answering various questions, providing information, engaging in conversations, and more. How can I assist you?" - }, - "finish_reason": "", - "index": 0 - } - ], - "created": 1724257207, - "id": "5e5c94a2-d989-40b5-9965-5b971db941fe", - "model": "360gpt-turbo", - "object": "", - "usage": { - "completion_tokens": 33, - "prompt_tokens": 24, - "total_tokens": 57 - }, - "messages": [ - { - "role": "system", - "content": "You are a professional developer!" - }, - { - "role": "user", - "content": "Hello, who are you?" - } - ], - "context": null -} -``` - -**Text Embedding Request Example** - -**URL**: http://your-domain/v1/embeddings - -**Request Example** - -```json -{ - "input":["Hello"], - "model":"text-embedding-3-small" -} -``` - -**Response Example** - -```json -{ - "data": [ - { - "embedding": [ - -0.011237, - -0.015433, - ..., - -0.028946, - -0.052778, - 0.003768, - -0.007917, - -0.042201 - ], - "index": 0, - "object": "" - } - ], - "model": "embedding_s1_v1.2", - "object": "", - "usage": { - "prompt_tokens": 2, - "total_tokens": 2 - } -} -``` - -### Using OpenAI Protocol Proxy for Cloudflare Workers AI Service - +### Using OpenAI Protocol to Proxy Cloudflare Workers AI Service **Configuration Information** - ```yaml provider: type: cloudflare @@ -1074,48 +324,8 @@ provider: modelMapping: "*": "@cf/meta/llama-3-8b-instruct" ``` - -**Request Example** - -```json -{ - "model": "gpt-3.5", - "max_tokens": 1024, - "messages": [ - { - "role": "user", - "content": "Who are you?" - } - ] -} -``` - -**Response Example** - -```json -{ - "id": "id-1720367803430", - "object": "chat.completion", - "created": 1720367803, - "model": "@cf/meta/llama-3-8b-instruct", - "choices": [ - { - "index": 0, - "message": { - "role": "assistant", - "content": "I am LLaMA, an AI assistant developed by Meta AI that can understand and respond to human input in a conversational manner. I'm not a human, but a computer program designed to simulate conversation and answer questions to the best of my knowledge. I can be used to generate text on a wide range of topics, from science and history to entertainment and culture." - }, - "logprobs": null, - "finish_reason": "stop" - } - ] -} -``` - -### Using OpenAI Protocol Proxy for Spark Service - +### Using OpenAI Protocol to Proxy Spark Service **Configuration Information** - ```yaml provider: type: spark @@ -1126,55 +336,8 @@ provider: "gpt-4": "generalv3" "*": "general" ``` - -**Request Example** - -```json -{ - "model": "gpt-4o", - "messages": [ - { - "role": "system", - "content": "You are a professional developer!" - }, - { - "role": "user", - "content": "Hello, who are you?" - } - ], - "stream": false -} -``` - -**Response Example** - -```json -{ - "id": "cha000c23c6@dx190ef0b4b96b8f2532", - "choices": [ - { - "index": 0, - "message": { - "role": "assistant", - "content": "Hello! I am a professional developer skilled in programming and problem-solving. What can I assist you with?" - } - } - ], - "created": 1721997415, - "model": "generalv3.5", - "object": "chat.completion", - "usage": { - "prompt_tokens": 10, - "completion_tokens": 19, - "total_tokens": 29 - } -} -``` - -### Utilizing OpenAI Protocol Proxy for Gemini Services - +### Using OpenAI Protocol to Proxy Gemini Service **Configuration Information** - ```yaml provider: type: gemini @@ -1183,57 +346,13 @@ provider: modelMapping: "*": "gemini-pro" geminiSafetySetting: - "HARM_CATEGORY_SEXUALLY_EXPLICIT" :"BLOCK_NONE" - "HARM_CATEGORY_HATE_SPEECH" :"BLOCK_NONE" - "HARM_CATEGORY_HARASSMENT" :"BLOCK_NONE" - "HARM_CATEGORY_DANGEROUS_CONTENT" :"BLOCK_NONE" -``` - -**Request Example** - -```json -{ - "model": "gpt-3.5", - "messages": [ - { - "role": "user", - "content": "Who are you?" - } - ], - "stream": false -} -``` - -**Response Example** - -```json -{ - "id": "chatcmpl-b010867c-0d3f-40ba-95fd-4e8030551aeb", - "choices": [ - { - "index": 0, - "message": { - "role": "assistant", - "content": "I am a large multi-modal model, trained by Google. I am designed to provide information and answer questions to the best of my abilities." - }, - "finish_reason": "stop" - } - ], - "created": 1722756984, - "model": "gemini-pro", - "object": "chat.completion", - "usage": { - "prompt_tokens": 5, - "completion_tokens": 29, - "total_tokens": 34 - } -} + "HARM_CATEGORY_SEXUALLY_EXPLICIT": "BLOCK_NONE" + "HARM_CATEGORY_HATE_SPEECH": "BLOCK_NONE" + "HARM_CATEGORY_HARASSMENT": "BLOCK_NONE" + "HARM_CATEGORY_DANGEROUS_CONTENT": "BLOCK_NONE" ``` - -### Utilizing OpenAI Protocol Proxy for DeepL Text Translation Service - +### Using OpenAI Protocol to Proxy DeepL Text Translation Service **Configuration Information** - ```yaml provider: type: deepl @@ -1241,10 +360,8 @@ provider: - "YOUR_DEEPL_API_TOKEN" targetLang: "ZH" ``` - **Request Example** -Here, `model` denotes the service tier of DeepL and can only be either `Free` or `Pro`. The `content` field contains the text to be translated; within `role: system`, `content` may include context that influences the translation but isn't translated itself. For instance, when translating product names, including a product description as context could enhance translation quality. - +In this context, `model` indicates the type of DeepL service, which can only be `Free` or `Pro`. The `content` sets the text to be translated; in the `role: system` `content`, context that may affect the translation but itself will not be translated can be included. For example, when translating product names, product descriptions can be passed as context, and this additional context may improve the quality of the translation. ```json { "model": "Free", @@ -1262,18 +379,17 @@ Here, `model` denotes the service tier of DeepL and can only be either `Free` or ] } ``` - **Response Example** ```json { "choices": [ { "index": 0, - "message": { "name": "EN", "role": "assistant", "content": "operate a gambling establishment" } + "message": { "name": "EN", "role": "assistant", "content": "坐庄" } }, { "index": 1, - "message": { "name": "EN", "role": "assistant", "content": "Bank of China" } + "message": { "name": "EN", "role": "assistant", "content": "中国银行" } } ], "created": 1722747752, @@ -1282,206 +398,3 @@ Here, `model` denotes the service tier of DeepL and can only be either `Free` or "usage": {} } ``` - -## Full Configuration Example - -### Kubernetes Example - -Here's a full plugin configuration example using the OpenAI protocol proxy for Groq services. - -```yaml -apiVersion: extensions.higress.io/v1alpha1 -kind: WasmPlugin -metadata: - name: ai-proxy-groq - namespace: higress-system -spec: - matchRules: - - config: - provider: - type: groq - apiTokens: - - "YOUR_API_TOKEN" - ingress: - - groq - url: oci://higress-registry.cn-hangzhou.cr.aliyuncs.com/plugins/ai-proxy:1.0.0 ---- -apiVersion: networking.k8s.io/v1 -kind: Ingress -metadata: - annotations: - higress.io/backend-protocol: HTTPS - higress.io/destination: groq.dns - higress.io/proxy-ssl-name: api.groq.com - higress.io/proxy-ssl-server-name: "on" - labels: - higress.io/resource-definer: higress - name: groq - namespace: higress-system -spec: - ingressClassName: higress - rules: - - host: - http: - paths: - - backend: - resource: - apiGroup: networking.higress.io - kind: McpBridge - name: default - path: / - pathType: Prefix ---- -apiVersion: networking.higress.io/v1 -kind: McpBridge -metadata: - name: default - namespace: higress-system -spec: - registries: - - domain: api.groq.com - name: groq - port: 443 - type: dns -``` - -Access Example: - -```bash -curl "http:///v1/chat/completions" -H "Content-Type: application/json" -d '{ - "model": "llama3-8b-8192", - "messages": [ - { - "role": "user", - "content": "hello, who are you?" - } - ] -}' -``` - -### Docker-Compose Example - -`docker-compose.yml` configuration file: - -```yaml -version: '3.7' -services: - envoy: - image: higress-registry.cn-hangzhou.cr.aliyuncs.com/higress/envoy:1.20 - entrypoint: /usr/local/bin/envoy - # Enables debug level logging for easier debugging - command: -c /etc/envoy/envoy.yaml --component-log-level wasm:debug - networks: - - higress-net - ports: - - "10000:10000" - volumes: - - ./envoy.yaml:/etc/envoy/envoy.yaml - - ./plugin.wasm:/etc/envoy/plugin.wasm -networks: - higress-net: {} -``` - -`envoy.yaml` configuration file: - -```yaml -admin: - address: - socket_address: - protocol: TCP - address: 0.0.0.0 - port_value: 9901 -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 - scheme_header_transformation: - scheme_to_overwrite: https - stat_prefix: ingress_http - # Outputs envoy logs to stdout - access_log: - - name: envoy.access_loggers.stdout - typed_config: - "@type": type.googleapis.com/envoy.extensions.access_loggers.stream.v3.StdoutAccessLog - # Modify as needed - route_config: - name: local_route - virtual_hosts: - - name: local_service - domains: [ "*" ] - routes: - - match: - prefix: "/" - route: - cluster: claude - timeout: 300s - http_filters: - - name: claude - 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: claude - vm_config: - runtime: envoy.wasm.runtime.v8 - code: - local: - filename: /etc/envoy/plugin.wasm - configuration: - "@type": "type.googleapis.com/google.protobuf.StringValue" - value: | # Plugin configuration - { - "provider": { - "type": "claude", - "apiTokens": [ - "YOUR_API_TOKEN" - ] - } - } - - name: envoy.filters.http.router - clusters: - - name: claude - connect_timeout: 30s - type: LOGICAL_DNS - dns_lookup_family: V4_ONLY - lb_policy: ROUND_ROBIN - load_assignment: - cluster_name: claude - endpoints: - - lb_endpoints: - - endpoint: - address: - socket_address: - address: api.anthropic.com # Service address - port_value: 443 - transport_socket: - name: envoy.transport_sockets.tls - typed_config: - "@type": type.googleapis.com/envoy.extensions.transport_sockets.tls.v3.UpstreamTlsContext - "sni": "api.anthropic.com" -``` - -Access Example: - -```bash -curl "http://localhost:10000/v1/chat/completions" -H "Content-Type: application/json" -d '{ - "model": "claude-3-opus-20240229", - "max_tokens": 1024, - "messages": [ - { - "role": "user", - "content": "hello, who are you?" - } - ] -}' -``` diff --git a/backend/sdk/src/main/resources/plugins/ai-security-guard/README.md b/backend/sdk/src/main/resources/plugins/ai-security-guard/README.md index 1a414eae..122d9e36 100644 --- a/backend/sdk/src/main/resources/plugins/ai-security-guard/README.md +++ b/backend/sdk/src/main/resources/plugins/ai-security-guard/README.md @@ -1,22 +1,157 @@ +--- +title: AI内容安全 +keywords: [higress, AI, security] +description: 阿里云内容安全检测 +--- + ## 功能说明 +通过对接阿里云内容安全检测大模型的输入输出,保障AI应用内容合法合规。 + +## 运行属性 + +插件执行阶段:`默认阶段` +插件执行优先级:`300` ## 配置说明 | Name | Type | Requirement | Default | Description | -| --- | --- | --- | --- | --- | -| serviceSource | string | requried | - | 服务来源,填dns | -| serviceName | string | requried | - | 服务名 | -| servicePort | string | requried | - | 服务端口 | -| domain | string | requried | - | 阿里云内容安全endpoint | -| ak | string | requried | - | 阿里云AK | -| sk | string | requried | - | 阿里云SK | +| ------------ | ------------ | ------------ | ------------ | ------------ | +| `serviceName` | string | requried | - | 服务名 | +| `servicePort` | string | requried | - | 服务端口 | +| `serviceHost` | string | requried | - | 阿里云内容安全endpoint的域名 | +| `accessKey` | string | requried | - | 阿里云AK | +| `secretKey` | string | requried | - | 阿里云SK | +| `checkRequest` | bool | optional | false | 检查提问内容是否合规 | +| `checkResponse` | bool | optional | false | 检查大模型的回答内容是否合规,生效时会使流式响应变为非流式 | +| `requestCheckService` | string | optional | llm_query_moderation | 指定阿里云内容安全用于检测输入内容的服务 | +| `responseCheckService` | string | optional | llm_response_moderation | 指定阿里云内容安全用于检测输出内容的服务 | +| `requestContentJsonPath` | string | optional | `messages.@reverse.0.content` | 指定要检测内容在请求body中的jsonpath | +| `responseContentJsonPath` | string | optional | `choices.0.message.content` | 指定要检测内容在响应body中的jsonpath | +| `responseStreamContentJsonPath` | string | optional | `choices.0.delta.content` | 指定要检测内容在流式响应body中的jsonpath | +| `denyCode` | int | optional | 200 | 指定内容非法时的响应状态码 | +| `denyMessage` | string | optional | openai格式的流式/非流式响应 | 指定内容非法时的响应内容 | +| `protocol` | string | optional | openai | 协议格式,非openai协议填`original` | + +补充说明一下 `denyMessage`,对于openai格式的请求,对非法请求的处理逻辑为: +- 如果配置了 `denyMessage` + - 优先返回阿里云内容安全的建议回答,格式为openai格式的流式/非流式响应 + - 如果阿里云内容安全未返回建议的回答,返回内容为 `denyMessage` 配置内容,格式为openai格式的流式/非流式响应 +- 如果没有配置 `denyMessage` + - 优先返回阿里云内容安全的建议回答,格式为openai格式的流式/非流式响应 + - 如果阿里云内容安全未返回建议的回答,返回内容为内置的兜底回答,内容为`"很抱歉,我无法回答您的问题"`,格式为openai格式的流式/非流式响应 +如果用户使用了非openai格式的协议,应当配置 `denyMessage`,此时对非法请求的处理逻辑为: +- 返回用户配置的 `denyMessage` 内容,用户可以配置其为序列化后的json字符串,以保持与正常请求接口返回格式的一致性 +- 如果 `denyMessage` 为空,优先返回阿里云内容安全的建议回答,格式为纯文本 +- 如果阿里云内容安全未返回建议回答,返回内置的兜底回答,内容为`"很抱歉,我无法回答您的问题"`,格式为纯文本 ## 配置示例 +### 前提条件 +由于插件中需要调用阿里云内容安全服务,所以需要先创建一个DNS类型的服务,例如: + +![](https://img.alicdn.com/imgextra/i4/O1CN013AbDcn1slCY19inU2_!!6000000005806-0-tps-1754-1320.jpg) + +### 检测输入内容是否合规 + +```yaml +serviceName: safecheck.dns +servicePort: 443 +serviceHost: "green-cip.cn-shanghai.aliyuncs.com" +accessKey: "XXXXXXXXX" +secretKey: "XXXXXXXXXXXXXXX" +checkRequest: true +``` + +### 检测输入与输出是否合规 + ```yaml -serviceSource: "dns" -serviceName: "safecheck" +serviceName: safecheck.dns servicePort: 443 -domain: "green-cip.cn-shanghai.aliyuncs.com" -ak: "XXXXXXXXX" -sk: "XXXXXXXXXXXXXXX" +serviceHost: green-cip.cn-shanghai.aliyuncs.com +accessKey: "XXXXXXXXX" +secretKey: "XXXXXXXXXXXXXXX" +checkRequest: true +checkResponse: true +``` + +### 指定自定义内容安全检测服务 +用户可能需要根据不同的场景配置不同的检测规则,该问题可通过为不同域名/路由/服务配置不同的内容安全检测服务实现。如下图所示,我们创建了一个名为 llm_query_moderation_01 的检测服务,其中的检测规则在 llm_query_moderation 之上做了一些改动: + +![](https://img.alicdn.com/imgextra/i4/O1CN01bAtcvn1N9sB16iiZR_!!6000000001528-0-tps-2728-822.jpg) + +接下来在目标域名/路由/服务级别进行以下配置,指定使用我们自定义的 llm_query_moderation_01 中的规则进行检测: + +```yaml +serviceName: safecheck.dns +servicePort: 443 +serviceHost: "green-cip.cn-shanghai.aliyuncs.com" +accessKey: "XXXXXXXXX" +secretKey: "XXXXXXXXXXXXXXX" +checkRequest: true +requestCheckService: llm_query_moderation_01 +``` + +### 配置非openai协议(例如百炼App) + +```yaml +serviceName: safecheck.dns +servicePort: 443 +serviceHost: "green-cip.cn-shanghai.aliyuncs.com" +accessKey: "XXXXXXXXX" +secretKey: "XXXXXXXXXXXXXXX" +checkRequest: true +checkResponse: true +requestContentJsonPath: "input.prompt" +responseContentJsonPath: "output.text" +denyCode: 200 +denyMessage: "很抱歉,我无法回答您的问题" +protocol: original +``` + +## 可观测 +### Metric +ai-security-guard 插件提供了以下监控指标: +- `ai_sec_request_deny`: 请求内容安全检测失败请求数 +- `ai_sec_response_deny`: 模型回答安全检测失败请求数 + +### Trace +如果开启了链路追踪,ai-security-guard 插件会在请求 span 中添加以下 attributes: +- `ai_sec_risklabel`: 表示请求命中的风险类型 +- `ai_sec_deny_phase`: 表示请求被检测到风险的阶段(取值为request或者response) + +## 请求示例 +```bash +curl http://localhost/v1/chat/completions \ +-H "Content-Type: application/json" \ +-d '{ + "model": "gpt-4o-mini", + "messages": [ + { + "role": "user", + "content": "这是一段非法内容" + } + ] +}' +``` + +请求内容会被发送到阿里云内容安全服务进行检测,如果请求内容检测结果为非法,网关将返回形如以下的回答: + +```json +{ + "id": "chatcmpl-AAy3hK1dE4ODaegbGOMoC9VY4Sizv", + "object": "chat.completion", + "created": 1677652288, + "model": "gpt-4o-mini", + "system_fingerprint": "fp_44709d6fcb", + "choices": [ + { + "index": 0, + "message": { + "role": "assistant", + "content": "作为一名人工智能助手,我不能提供涉及色情、暴力、政治等敏感话题的内容。如果您有其他相关问题,欢迎您提问。", + }, + "logprobs": null, + "finish_reason": "stop" + } + ] +} ``` diff --git a/backend/sdk/src/main/resources/plugins/ai-security-guard/README_EN.md b/backend/sdk/src/main/resources/plugins/ai-security-guard/README_EN.md new file mode 100644 index 00000000..0367686a --- /dev/null +++ b/backend/sdk/src/main/resources/plugins/ai-security-guard/README_EN.md @@ -0,0 +1,69 @@ +--- +title: AI Content Security +keywords: [higress, AI, security] +description: Alibaba Cloud content security +--- + + +## Introduction +Integrate with Aliyun content security service for detections of input and output of LLMs, ensuring that application content is legal and compliant. + +## Runtime Properties + +Plugin Phase: `CUSTOM` +Plugin Priority: `300` + +## Configuration +| Name | Type | Requirement | Default | Description | +| ------------ | ------------ | ------------ | ------------ | ------------ | +| `serviceName` | string | requried | - | service name | +| `servicePort` | string | requried | - | service port | +| `serviceHost` | string | requried | - | Host of Aliyun content security service endpoint | +| `accessKey` | string | requried | - | Aliyun accesskey | +| `secretKey` | string | requried | - | Aliyun secretkey | +| `checkRequest` | bool | optional | false | check if the input is legal | +| `checkResponse` | bool | optional | false | check if the output is legal | +| `requestCheckService` | string | optional | llm_query_moderation | Aliyun yundun service name for input check | +| `responseCheckService` | string | optional | llm_response_moderation | Aliyun yundun service name for output check | +| `requestContentJsonPath` | string | optional | `messages.@reverse.0.content` | Specify the jsonpath of the content to be detected in the request body | +| `responseContentJsonPath` | string | optional | `choices.0.message.content` | Specify the jsonpath of the content to be detected in the response body | +| `responseStreamContentJsonPath` | string | optional | `choices.0.delta.content` | Specify the jsonpath of the content to be detected in the streaming response body | +| `denyCode` | int | optional | 200 | Response status code when the specified content is illegal | +| `denyMessage` | string | optional | Drainage/non-streaming response in openai format, the answer content is the suggested answer from Alibaba Cloud content security | Response content when the specified content is illegal | +| `protocol` | string | optional | openai | protocol format, `openai` or `original` | + + +## Examples of configuration +### Check if the input is legal + +```yaml +serviceName: safecheck.dns +servicePort: 443 +serviceHost: "green-cip.cn-shanghai.aliyuncs.com" +accessKey: "XXXXXXXXX" +secretKey: "XXXXXXXXXXXXXXX" +checkRequest: true +``` + +### Check if both the input and output are legal + +```yaml +serviceName: safecheck.dns +servicePort: 443 +serviceHost: green-cip.cn-shanghai.aliyuncs.com +accessKey: "XXXXXXXXX" +secretKey: "XXXXXXXXXXXXXXX" +checkRequest: true +checkResponse: true +``` + +## Observability +### Metric +ai-security-guard plugin provides following metrics: +- `ai_sec_request_deny`: count of requests denied at request phase +- `ai_sec_response_deny`: count of requests denied at response phase + +### Trace +ai-security-guard plugin provides following span attributes: +- `ai_sec_risklabel`: risk type of this request +- `ai_sec_deny_phase`: denied phase of this request, value can be request/response \ No newline at end of file diff --git a/backend/sdk/src/main/resources/plugins/ai-security-guard/spec.yaml b/backend/sdk/src/main/resources/plugins/ai-security-guard/spec.yaml index def4f507..b2df3c3c 100644 --- a/backend/sdk/src/main/resources/plugins/ai-security-guard/spec.yaml +++ b/backend/sdk/src/main/resources/plugins/ai-security-guard/spec.yaml @@ -22,20 +22,22 @@ spec: openAPIV3Schema: type: object example: - serviceSource: dns - serviceName: safecheck + serviceName: safecheck.dns # 请先创建好相应服务 servicePort: 443 - domain: green-cip.cn-shanghai.aliyuncs.com - ak: xxxxxxxxxxxxxxxxxxxx - sk: xxxxxxxxxxxxxxxxxxxx + serviceHost: green-cip.cn-hangzhou.aliyuncs.com + accessKey: "XXXXXXXXX" + secretKey: "XXXXXXXXXXXXXXX" + checkRequest: true + checkResponse: false routeConfigSchema: openAPIV3Schema: type: object example: - serviceSource: dns - serviceName: safecheck + serviceName: safecheck.dns # 请先创建好相应服务 servicePort: 443 - domain: green-cip.cn-shanghai.aliyuncs.com - ak: xxxxxxxxxxxxxxxxxxxx - sk: xxxxxxxxxxxxxxxxxxxx + serviceHost: green-cip.cn-hangzhou.aliyuncs.com + accessKey: "XXXXXXXXX" + secretKey: "XXXXXXXXXXXXXXX" + checkRequest: true + checkResponse: false diff --git a/backend/sdk/src/main/resources/plugins/ai-statistics/README.md b/backend/sdk/src/main/resources/plugins/ai-statistics/README.md index 211201be..31fb207f 100644 --- a/backend/sdk/src/main/resources/plugins/ai-statistics/README.md +++ b/backend/sdk/src/main/resources/plugins/ai-statistics/README.md @@ -1,69 +1,178 @@ -# 介绍 -提供AI可观测基础能力,其后需接ai-proxy插件,如果不接ai-proxy插件的话,则只支持openai协议。 +--- +title: AI可观测 +keywords: [higress, AI, observability] +description: AI可观测配置参考 +--- -# 配置说明 +## 介绍 +提供AI可观测基础能力,包括 metric, log, trace,其后需接ai-proxy插件,如果不接ai-proxy插件的话,则需要用户进行相应配置才可生效。 + +## 运行属性 + +插件执行阶段:`默认阶段` +插件执行优先级:`200` + +## 配置说明 +插件默认请求符合openai协议格式,并提供了以下基础可观测值,用户无需特殊配置: + +- metric:提供了输入token、输出token、首个token的rt(流式请求)、请求总rt等指标,支持在网关、路由、服务、模型四个维度上进行观测 +- log:提供了 input_token, output_token, model, llm_service_duration, llm_first_token_duration 等字段 + +用户还可以通过配置的方式对可观测的值进行扩展: | 名称 | 数据类型 | 填写要求 | 默认值 | 描述 | |----------------|-------|------|-----|------------------------| -| `enable` | bool | 必填 | - | 是否开启ai统计功能 | -| `tracing_span` | array | 非必填 | - | 自定义tracing span tag 配置 | +| `attributes` | []Attribute | 非必填 | - | 用户希望记录在log/span中的信息 | + +Attribute 配置说明: -## tracing_span 配置说明 | 名称 | 数据类型 | 填写要求 | 默认值 | 描述 | |----------------|-------|-----|-----|------------------------| -| `key` | string | 必填 | - | tracing tag 名称 | -| `value_source` | string | 必填 | - | tag 取值来源 | -| `value` | string | 必填 | - | tag 取值 key value/path | +| `key` | string | 必填 | - | attrribute 名称 | +| `value_source` | string | 必填 | - | attrribute 取值来源,可选值为 `fixed_value`, `request_header`, `request_body`, `response_header`, `response_body`, `response_streaming_body` | +| `value` | string | 必填 | - | attrribute 取值 key value/path | +| `rule` | string | 非必填 | - | 从流式响应中提取 attrribute 的规则,可选值为 `first`, `replace`, `append`| +| `apply_to_log` | bool | 非必填 | false | 是否将提取的信息记录在日志中 | +| `apply_to_span` | bool | 非必填 | false | 是否将提取的信息记录在链路追踪span中 | -value_source为 tag 值的取值来源,可选配置值有 4 个: -- property : tag 值通过proxywasm.GetProperty()方法获取,value配置GetProperty()方法要提取的key名 -- requeset_header : tag 值通过http请求头获取,value配置为header key -- request_body :tag 值通过请求body获取,value配置格式为 gjson的 GJSON PATH 语法 -- response_header : tag 值通过http响应头获取,value配置为header key +`value_source` 的各种取值含义如下: + +- `fixed_value`:固定值 +- `requeset_header` : attrribute 值通过 http 请求头获取,value 配置为 header key +- `request_body` :attrribute 值通过请求 body 获取,value 配置格式为 gjson 的 jsonpath +- `response_header` :attrribute 值通过 http 响应头获取,value 配置为header key +- `response_body` :attrribute 值通过响应 body 获取,value 配置格式为 gjson 的 jsonpath +- `response_streaming_body` :attrribute 值通过流式响应 body 获取,value 配置格式为 gjson 的 jsonpath + + +当 `value_source` 为 `response_streaming_body` 时,应当配置 `rule`,用于指定如何从流式body中获取指定值,取值含义如下: + +- `first`:多个chunk中取第一个有效chunk的值 +- `replace`:多个chunk中取最后一个有效chunk的值 +- `append`:拼接多个有效chunk中的值,可用于获取回答内容 + +## 配置示例 +如果希望在网关访问日志中记录ai-statistic相关的统计值,需要修改log_format,在原log_format基础上添加一个新字段,示例如下: -举例如下: ```yaml -tracing_label: -- key: "session_id" - value_source: "requeset_header" - value: "session_id" -- key: "user_content" - value_source: "request_body" - value: "input.messages.1.content" +'{"ai_log":"%FILTER_STATE(wasm.ai_log:PLAIN)%"}' ``` -开启后 metrics 示例: +### 空配置 +#### 监控 ``` -route_upstream_model_input_token{ai_route="openai",ai_cluster="qwen",ai_model="qwen-max"} 21 -route_upstream_model_output_token{ai_route="openai",ai_cluster="qwen",ai_model="qwen-max"} 17 +route_upstream_model_metric_input_token{ai_route="llm",ai_cluster="outbound|443||qwen.dns",ai_model="qwen-turbo"} 10 +route_upstream_model_metric_llm_duration_count{ai_route="llm",ai_cluster="outbound|443||qwen.dns",ai_model="qwen-turbo"} 1 +route_upstream_model_metric_llm_first_token_duration{ai_route="llm",ai_cluster="outbound|443||qwen.dns",ai_model="qwen-turbo"} 309 +route_upstream_model_metric_llm_service_duration{ai_route="llm",ai_cluster="outbound|443||qwen.dns",ai_model="qwen-turbo"} 1955 +route_upstream_model_metric_output_token{ai_route="llm",ai_cluster="outbound|443||qwen.dns",ai_model="qwen-turbo"} 69 ``` -日志示例: +#### 日志 +```json +{ + "ai_log":"{\"model\":\"qwen-turbo\",\"input_token\":\"10\",\"output_token\":\"69\",\"llm_first_token_duration\":\"309\",\"llm_service_duration\":\"1955\"}" +} +``` + +#### 链路追踪 +配置为空时,不会在span中添加额外的attribute + +### 从非openai协议提取token使用信息 +在ai-proxy中设置协议为original时,以百炼为例,可作如下配置指定如何提取model, input_token, output_token +```yaml +attributes: + - key: model + value_source: response_body + value: usage.models.0.model_id + apply_to_log: true + apply_to_span: false + - key: input_token + value_source: response_body + value: usage.models.0.input_tokens + apply_to_log: true + apply_to_span: false + - key: output_token + value_source: response_body + value: usage.models.0.output_tokens + apply_to_log: true + apply_to_span: false +``` +#### 监控 +``` +route_upstream_model_metric_input_token{ai_route="bailian",ai_cluster="qwen",ai_model="qwen-max"} 343 +route_upstream_model_metric_output_token{ai_route="bailian",ai_cluster="qwen",ai_model="qwen-max"} 153 +route_upstream_model_metric_llm_service_duration{ai_route="bailian",ai_cluster="qwen",ai_model="qwen-max"} 3725 +route_upstream_model_metric_llm_duration_count{ai_route="bailian",ai_cluster="qwen",ai_model="qwen-max"} 1 +``` + +#### 日志 +此配置下日志效果如下: ```json { - "model": "qwen-max", - "input_token": "21", - "output_token": "17", - "authority": "dashscope.aliyuncs.com", - "bytes_received": "336", - "bytes_sent": "1675", - "duration": "1590", - "istio_policy_status": "-", - "method": "POST", - "path": "/v1/chat/completions", - "protocol": "HTTP/1.1", - "request_id": "5895f5a9-e4e3-425b-98db-6c6a926195b7", - "requested_server_name": "-", - "response_code": "200", - "response_flags": "-", - "route_name": "openai", - "start_time": "2024-06-18T09:37:14.078Z", - "trace_id": "-", - "upstream_cluster": "qwen", - "upstream_service_time": "496", - "upstream_transport_failure_reason": "-", - "user_agent": "PostmanRuntime/7.37.3", - "x_forwarded_for": "-" + "ai_log": "{\"model\":\"qwen-max\",\"input_token\":\"343\",\"output_token\":\"153\",\"llm_service_duration\":\"19110\"}" } +``` + +#### 链路追踪 +链路追踪的 span 中可以看到 model, input_token, output_token 三个额外的 attribute + +### 配合认证鉴权记录consumer +举例如下: +```yaml +attributes: + - key: consumer # 配合认证鉴权记录consumer + value_source: request_header + value: x-mse-consumer + apply_to_log: true +``` + +### 记录问题与回答 +```yaml +attributes: + - key: question # 记录问题 + value_source: request_body + value: messages.@reverse.0.content + apply_to_log: true + - key: answer # 在流式响应中提取大模型的回答 + value_source: response_streaming_body + value: choices.0.delta.content + rule: append + apply_to_log: true + - key: answer # 在非流式响应中提取大模型的回答 + value_source: response_body + value: choices.0.message.content + apply_to_log: true +``` + +## 进阶 +配合阿里云SLS数据加工,可以将ai相关的字段进行提取加工,例如原始日志为: + +``` +ai_log:{"question":"用python计算2的3次方","answer":"你可以使用 Python 的乘方运算符 `**` 来计算一个数的次方。计算2的3次方,即2乘以自己2次,可以用以下代码表示:\n\n```python\nresult = 2 ** 3\nprint(result)\n```\n\n运行这段代码,你会得到输出结果为8,因为2乘以自己两次等于8。","model":"qwen-max","input_token":"16","output_token":"76","llm_service_duration":"5913"} +``` + +使用如下数据加工脚本,可以提取出question和answer: + +``` +e_regex("ai_log", grok("%{EXTRACTJSON}")) +e_set("question", json_select(v("json"), "question", default="-")) +e_set("answer", json_select(v("json"), "answer", default="-")) +``` + +提取后,SLS中会添加question和answer两个字段,示例如下: + +``` +ai_log:{"question":"用python计算2的3次方","answer":"你可以使用 Python 的乘方运算符 `**` 来计算一个数的次方。计算2的3次方,即2乘以自己2次,可以用以下代码表示:\n\n```python\nresult = 2 ** 3\nprint(result)\n```\n\n运行这段代码,你会得到输出结果为8,因为2乘以自己两次等于8。","model":"qwen-max","input_token":"16","output_token":"76","llm_service_duration":"5913"} + +question:用python计算2的3次方 + +answer:你可以使用 Python 的乘方运算符 `**` 来计算一个数的次方。计算2的3次方,即2乘以自己2次,可以用以下代码表示: + +result = 2 ** 3 +print(result) + +运行这段代码,你会得到输出结果为8,因为2乘以自己两次等于8。 + ``` \ No newline at end of file diff --git a/backend/sdk/src/main/resources/plugins/ai-statistics/README_EN.md b/backend/sdk/src/main/resources/plugins/ai-statistics/README_EN.md new file mode 100644 index 00000000..e94544a5 --- /dev/null +++ b/backend/sdk/src/main/resources/plugins/ai-statistics/README_EN.md @@ -0,0 +1,145 @@ +--- +title: AI Statistics +keywords: [higress, AI, observability] +description: AI Statistics plugin configuration reference +--- + +## Introduction +Provides basic AI observability capabilities, including metric, log, and trace. The ai-proxy plug-in needs to be connected afterwards. If the ai-proxy plug-in is not connected, the user needs to configure it accordingly to take effect. + +## Runtime Properties + +Plugin Phase: `CUSTOM` +Plugin Priority: `200` + +## Configuration instructions +The default request of the plug-in conforms to the openai protocol format and provides the following basic observable values. Users do not need special configuration: + +- metric: It provides indicators such as input token, output token, rt of the first token (streaming request), total request rt, etc., and supports observation in the four dimensions of gateway, routing, service, and model. +- log: Provides input_token, output_token, model, llm_service_duration, llm_first_token_duration and other fields + +Users can also expand observable values ​​through configuration: + +| Name | Type | Required | Default | Description | +|----------------|-------|------|-----|------------------------| +| `attributes` | []Attribute | required | - | Information that the user wants to record in log/span | + +Attribute Configuration instructions: + +| Name | Type | Required | Default | Description | +|----------------|-------|-----|-----|------------------------| +| `key` | string | required | - | attrribute key | +| `value_source` | string | required | - | attrribute value source, optional values ​​are `fixed_value`, `request_header`, `request_body`, `response_header`, `response_body`, `response_streaming_body` | +| `value` | string | required | - | how to get attrribute value | +| `rule` | string | optional | - | Rule to extract attribute from streaming response, optional values ​​are `first`, `replace`, `append`| +| `apply_to_log` | bool | optional | false | Whether to record the extracted information in the log | +| `apply_to_span` | bool | optional | false | Whether to record the extracted information in the link tracking span | + +The meanings of various values for `value_source` ​​are as follows: + +- `fixed_value`: fixed value +- `requeset_header`: The attrribute is obtained through the http request header +- `request_body`: The attrribute is obtained through the http request body +- `response_header`: The attrribute is obtained through the http response header +- `response_body`: The attrribute is obtained through the http response body +- `response_streaming_body`: The attrribute is obtained through the http streaming response body + + +When `value_source` is `response_streaming_body`, `rule` should be configured to specify how to obtain the specified value from the streaming body. The meaning of the value is as follows: + +- `first`: extract value from the first valid chunk +- `replace`: extract value from the last valid chunk +- `append`: join value pieces from all valid chunks + +## Configuration example +If you want to record ai-statistic related statistical values ​​​​in the gateway access log, you need to modify log_format and add a new field based on the original log_format. The example is as follows: + +```yaml +'{"ai_log":"%FILTER_STATE(wasm.ai_log:PLAIN)%"}' +``` + +### Empty +#### Metric +``` +route_upstream_model_metric_input_token{ai_route="llm",ai_cluster="outbound|443||qwen.dns",ai_model="qwen-turbo"} 10 +route_upstream_model_metric_llm_duration_count{ai_route="llm",ai_cluster="outbound|443||qwen.dns",ai_model="qwen-turbo"} 1 +route_upstream_model_metric_llm_first_token_duration{ai_route="llm",ai_cluster="outbound|443||qwen.dns",ai_model="qwen-turbo"} 309 +route_upstream_model_metric_llm_service_duration{ai_route="llm",ai_cluster="outbound|443||qwen.dns",ai_model="qwen-turbo"} 1955 +route_upstream_model_metric_output_token{ai_route="llm",ai_cluster="outbound|443||qwen.dns",ai_model="qwen-turbo"} 69 +``` + +#### Log +```json +{ + "ai_log":"{\"model\":\"qwen-turbo\",\"input_token\":\"10\",\"output_token\":\"69\",\"llm_first_token_duration\":\"309\",\"llm_service_duration\":\"1955\"}" +} +``` + +#### Trace +When the configuration is empty, no additional attributes will be added to the span. + +### Extract token usage information from non-openai protocols +When setting the protocol to original in ai-proxy, taking Alibaba Cloud Bailian as an example, you can make the following configuration to specify how to extract `model`, `input_token`, `output_token` + +```yaml +attributes: + - key: model + value_source: response_body + value: usage.models.0.model_id + apply_to_log: true + apply_to_span: false + - key: input_token + value_source: response_body + value: usage.models.0.input_tokens + apply_to_log: true + apply_to_span: false + - key: output_token + value_source: response_body + value: usage.models.0.output_tokens + apply_to_log: true + apply_to_span: false +``` +#### Metric +``` +route_upstream_model_metric_input_token{ai_route="bailian",ai_cluster="qwen",ai_model="qwen-max"} 343 +route_upstream_model_metric_output_token{ai_route="bailian",ai_cluster="qwen",ai_model="qwen-max"} 153 +route_upstream_model_metric_llm_service_duration{ai_route="bailian",ai_cluster="qwen",ai_model="qwen-max"} 3725 +route_upstream_model_metric_llm_duration_count{ai_route="bailian",ai_cluster="qwen",ai_model="qwen-max"} 1 +``` + +#### Log +```json +{ + "ai_log": "{\"model\":\"qwen-max\",\"input_token\":\"343\",\"output_token\":\"153\",\"llm_service_duration\":\"19110\"}" +} +``` + +#### Trace +Three additional attributes `model`, `input_token`, and `output_token` can be seen in the trace spans. + +### Cooperate with authentication and authentication record consumer +```yaml +attributes: + - key: consumer + value_source: request_header + value: x-mse-consumer + apply_to_log: true +``` + +### Record questions and answers +```yaml +attributes: + - key: question + value_source: request_body + value: messages.@reverse.0.content + apply_to_log: true + - key: answer + value_source: response_streaming_body + value: choices.0.delta.content + rule: append + apply_to_log: true + - key: answer + value_source: response_body + value: choices.0.message.content + apply_to_log: true +``` \ No newline at end of file diff --git a/backend/sdk/src/main/resources/plugins/ai-statistics/spec.yaml b/backend/sdk/src/main/resources/plugins/ai-statistics/spec.yaml index 990b9ac8..84279848 100644 --- a/backend/sdk/src/main/resources/plugins/ai-statistics/spec.yaml +++ b/backend/sdk/src/main/resources/plugins/ai-statistics/spec.yaml @@ -22,9 +22,29 @@ spec: openAPIV3Schema: type: object example: - enable: true routeConfigSchema: openAPIV3Schema: type: object example: - enable: true + # attributes: + # - key: consumer # 配合认证鉴权记录consumer + # value_source: request_header + # value: x-mse-consumer + # apply_to_log: true # 是否将信息添加到日志中 + # apply_to_span: false # 是否将信息添加到链路追踪span attribute中 + # - key: question # 记录问题 + # value_source: request_body + # value: messages.@reverse.0.content + # apply_to_log: true + # apply_to_span: false + # - key: answer # 在流式响应中提取大模型的回答 + # value_source: response_streaming_body + # value: choices.0.delta.content + # rule: append + # apply_to_log: true + # apply_to_span: false + # - key: answer # 在非流式响应中提取大模型的回答 + # value_source: response_body + # value: choices.0.message.content + # apply_to_log: true + # apply_to_span: false diff --git a/backend/sdk/src/main/resources/plugins/basic-auth/spec.yaml b/backend/sdk/src/main/resources/plugins/basic-auth/spec.yaml index 8757cbdd..f6104610 100644 --- a/backend/sdk/src/main/resources/plugins/basic-auth/spec.yaml +++ b/backend/sdk/src/main/resources/plugins/basic-auth/spec.yaml @@ -38,7 +38,7 @@ spec: - consumer1 consumers: type: array - scope: GLOBAL + x-scope: GLOBAL title: 调用方列表 x-title-i18n: en-US: Consumer List @@ -81,7 +81,7 @@ spec: properties: global_auth: type: boolean - scope: GLOBAL + x-scope: GLOBAL title: 是否开启全局认证 x-title-i18n: en-US: Enable Global Auth @@ -91,7 +91,7 @@ spec: example: false consumers: type: array - scope: GLOBAL + x-scope: GLOBAL title: 调用方列表 x-title-i18n: en-US: Consumer List diff --git a/backend/sdk/src/main/resources/plugins/ext-auth/README.md b/backend/sdk/src/main/resources/plugins/ext-auth/README.md index 4b16734a..abca6232 100644 --- a/backend/sdk/src/main/resources/plugins/ext-auth/README.md +++ b/backend/sdk/src/main/resources/plugins/ext-auth/README.md @@ -35,13 +35,14 @@ description: Ext 认证插件实现了调用外部授权服务进行认证鉴权 `endpoint`中每一项的配置字段说明 -| 名称 | 数据类型 | 必填 | 默认值 | 描述 | -| -------- | -------- | -- | ------ |-----------------------------------------------------------------------------------------| -| `service_name` | string | 必填 | - | 输入授权服务名称,带服务类型的完整 FQDN 名称,例如 `ext-auth.dns` 、`ext-auth.my-ns.svc.cluster.local` | -| `service_port` | int | 否 | 80 | 输入授权服务的服务端口 | -| `path_prefix` | string | `endpoint_mode` 为`envoy`时必填 | | `endpoint_mode` 为`envoy` 时,客户端向授权服务发送请求的请求路径前缀 | -| `request_method` | string | 否 | GET | `endpoint_mode` 为`forward_auth` 时,客户端向授权服务发送请求的HTTP Method | -| `path` | string | `endpoint_mode` 为`forward_auth`时必填 | - | `endpoint_mode` 为`forward_auth` 时,客户端向授权服务发送请求的请求路径 | +| 名称 | 数据类型 | 必填 | 默认值 | 描述 | +| -------- | -------- | -- | ------ | ----------------------------------------------------------------------------------------- | +| `service_name` | string | 必填 | - | 输入授权服务名称,带服务类型的完整 FQDN 名称,例如 `ext-auth.dns` 、`ext-auth.my-ns.svc.cluster.local` | +| `service_port` | int | 否 | 80 | 输入授权服务的服务端口 | +| `service_host` | string | 否 | - | 请求授权服务时设置的Host头,不填时和FQDN保持一致 | +| `path_prefix` | string | `endpoint_mode` 为`envoy`时必填 | | `endpoint_mode` 为`envoy` 时,客户端向授权服务发送请求的请求路径前缀 | +| `request_method` | string | 否 | GET | `endpoint_mode` 为`forward_auth` 时,客户端向授权服务发送请求的HTTP Method | +| `path` | string | `endpoint_mode` 为`forward_auth`时必填 | - | `endpoint_mode` 为`forward_auth` 时,客户端向授权服务发送请求的请求路径 | `authorization_request`中每一项的配置字段说明 @@ -108,7 +109,7 @@ curl -X POST http://localhost:8082/users?apikey=9a342114-ba8a-11ec-b1bf-00163e12 ``` POST /auth/users?apikey=9a342114-ba8a-11ec-b1bf-00163e1250b5 HTTP/1.1 -Host: ext-auth +Host: ext-auth.backend.svc.cluster.local Authorization: xxx Content-Length: 0 ``` @@ -147,9 +148,10 @@ http_service: allowed_upstream_headers: - exact: x-user-id - exact: x-auth-version - endpoint_mode: envoy + endpoint_mode: envoy endpoint: service_name: ext-auth.backend.svc.cluster.local + service_host: my-domain.local service_port: 8090 path_prefix: /auth timeout: 1000 @@ -165,7 +167,7 @@ curl -X POST http://localhost:8082/users?apikey=9a342114-ba8a-11ec-b1bf-00163e12 ``` POST /auth/users?apikey=9a342114-ba8a-11ec-b1bf-00163e1250b5 HTTP/1.1 -Host: ext-auth +Host: my-domain.local Authorization: xxx X-Auth-Version: 1.0 x-envoy-header: true @@ -205,7 +207,7 @@ curl -i http://localhost:8082/users?apikey=9a342114-ba8a-11ec-b1bf-00163e1250b5 ``` POST /auth HTTP/1.1 -Host: ext-auth +Host: ext-auth.backend.svc.cluster.local Authorization: xxx X-Original-Uri: /users?apikey=9a342114-ba8a-11ec-b1bf-00163e1250b5 X-Original-Method: GET @@ -246,9 +248,10 @@ http_service: allowed_upstream_headers: - exact: x-user-id - exact: x-auth-version - endpoint_mode: forward_auth + endpoint_mode: forward_auth endpoint: service_name: ext-auth.backend.svc.cluster.local + service_host: my-domain.local service_port: 8090 path: /auth request_method: POST @@ -265,7 +268,7 @@ curl -i http://localhost:8082/users?apikey=9a342114-ba8a-11ec-b1bf-00163e1250b5 ``` POST /auth HTTP/1.1 -Host: ext-auth +Host: my-domain.local Authorization: xxx X-Original-Uri: /users?apikey=9a342114-ba8a-11ec-b1bf-00163e1250b5 X-Original-Method: GET diff --git a/backend/sdk/src/main/resources/plugins/ext-auth/README_EN.md b/backend/sdk/src/main/resources/plugins/ext-auth/README_EN.md index eda7e3cc..949af49c 100644 --- a/backend/sdk/src/main/resources/plugins/ext-auth/README_EN.md +++ b/backend/sdk/src/main/resources/plugins/ext-auth/README_EN.md @@ -1,73 +1,75 @@ --- title: External Authentication keywords: [higress, auth] -description: The Ext authentication plugin implements the functionality to call external authorization services for authentication and authorization. +description: The Ext Authentication plugin implements the capability to call external authorization services for authentication and authorization. --- ## Function Description -The `ext-auth` plugin implements the ability to send authorization requests to external authorization services to check whether client requests are authorized. This plugin is based on the [ext_authz filter](https://www.envoyproxy.io/docs/envoy/latest/configuration/http/http_filters/ext_authz_filter) of Envoy, and implements part of the capability of connecting HTTP services found in the native filter. +The `ext-auth` plugin implements sending authentication requests to an external authorization service to check whether the client request is authorized. This plugin is implemented with reference to Envoy's native [ext_authz filter](https://www.envoyproxy.io/docs/envoy/latest/configuration/http/http_filters/ext_authz_filter), which covers some capabilities for connecting to HTTP services. -## Runtime Attributes -Plugin Execution Phase: `Authentication Phase` +## Execution Properties +Plugin Execution Phase: `Authentication Phase` Plugin Execution Priority: `360` ## Configuration Fields -| Name | Data Type | Required | Default Value | Description | -| ------------------------------- | --------- | -------- | ------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `http_service` | object | Yes | - | Configuration for the external authorization service | -| `failure_mode_allow` | bool | No | false | When set to true, client requests will still be accepted even if communication with the authorization service fails, or if the authorization service returns an HTTP 5xx error. | -| `failure_mode_allow_header_add` | bool | No | false | When both `failure_mode_allow` and `failure_mode_allow_header_add` are set to true, if communication with the authorization service fails or if the authorization service returns an HTTP 5xx error, the request header will add `x-envoy-auth-failure-mode-allowed: true`. | -| `status_on_error` | int | No | 403 | Sets the HTTP status code returned to the client when the authorization service is unreachable or returns a status code of 5xx. The default status code is `403`. | - -Description of each configuration field under `http_service` +| Name | Data Type | Required | Default Value | Description | +| ------------------------------- | --------- | -------- | ------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `http_service` | object | Yes | - | Configuration for the external authorization service | +| `failure_mode_allow` | bool | No | false | When set to true, client requests will still be accepted even if communication with the authorization service fails or the authorization service returns an HTTP 5xx error | +| `failure_mode_allow_header_add` | bool | No | false | When both `failure_mode_allow` and `failure_mode_allow_header_add` are set to true, if communication with the authorization service fails or returns an HTTP 5xx error, the request header will include `x-envoy-auth-failure-mode-allowed: true` | +| `status_on_error` | int | No | 403 | Sets the HTTP status code returned to the client when the authorization service is unreachable or returns a 5xx status code. The default status code is `403` | + +### Configuration Fields for Each Item in `http_service` | Name | Data Type | Required | Default Value | Description | | ------------------------ | --------- | -------- | ------------- | -------------------------------------------- | -| `endpoint_mode` | string | No | envoy | Choose one from `envoy`, `forward_auth`. | -| `endpoint` | object | Yes | - | HTTP service information for sending authorization requests. | -| `timeout` | int | No | 1000 | Timeout for connecting to `ext-auth` service, in milliseconds. | -| `authorization_request` | object | No | - | Configuration for sending authorization requests. | -| `authorization_response` | object | No | - | Configuration for processing authorization responses. | - -Description of each configuration field under `endpoint` -| Name | Data Type | Required | Default Value | Description | -|------------|-----------|----------|---------------|----------------------------------------------------------------------------------------------| -| `service_name` | string | Required | - | The full FQDN name of the authorization service, e.g., `ext-auth.dns`, `ext-auth.my-ns.svc.cluster.local`. | -| `service_port` | int | No | 80 | The service port of the authorization service. | -| `path_prefix` | string | Required if `endpoint_mode` is `envoy` | | When `endpoint_mode` is `envoy`, this is the request path prefix sent by the client to the authorization service. | -| `request_method` | string | No | GET | When `endpoint_mode` is `forward_auth`, this is the HTTP Method sent by the client to the authorization service. | -| `path` | string | Required if `endpoint_mode` is `forward_auth` | - | When `endpoint_mode` is `forward_auth`, this is the request path sent by the client to the authorization service. | - -Description of each configuration field under `authorization_request` -| Name | Data Type | Required | Default Value | Description | -|-------------------------|------------------------|----------|---------------|------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| -| `allowed_headers` | array of StringMatcher | No | - | When set, client request headers that match will be added to the request to the authorization service. In addition to user-defined header matching rules, the `Authorization` HTTP header will automatically be included in the authorization service request. (If `endpoint_mode` is `forward_auth`, the original request's path will be set to `X-Original-Uri`, and the original request's HTTP Method will be set to `X-Original-Method`.) | -| `headers_to_add` | `map[string]string` | No | - | Sets the list of request headers to include in the request to the authorization service. Note that headers with the same name from the client request will be overwritten. | -| `with_request_body` | bool | No | false | Buffers the client request body and sends it in the authorization request (does not take effect for HTTP Methods GET, OPTIONS, HEAD). | -| `max_request_body_bytes`| int | No | 10MB | Sets the maximum size of the client request body to be stored in memory. When the request body reaches the value set in this field, an HTTP 413 status code will be returned, and the authorization process will not be initiated. Note that this setting takes precedence over the configuration of `failure_mode_allow`. | - -Description of each configuration field under `authorization_response` -| Name | Data Type | Required | Default Value | Description | -|---------------------------|------------------------|----------|---------------|-----------------------------------------------------------------------------------------| -| `allowed_upstream_headers` | array of StringMatcher | No | - | When set, response headers from the authorization request that match will be added to the original client request headers. Note that headers with the same name will be overwritten. | -| `allowed_client_headers` | array of StringMatcher | No | - | If not set, when a request is denied, all response headers from the authorization request will be added to the client response. When set, in the case of a denied request, response headers from the authorization request that match will be added to the client response. | - -Configuration of the `StringMatcher` type, when using `array of StringMatcher`, will be configured in the order defined in the array -| Name | Data Type | Required | Default Value | Description | -|------------|-----------|------------------------------------------------------------------|---------------|------------------| -| `exact` | string | No, choose one from `exact`, `prefix`, `suffix`, `contains`, `regex` | - | Exact match | -| `prefix` | string | No, choose one from `exact`, `prefix`, `suffix`, `contains`, `regex` | - | Prefix match | -| `suffix` | string | No, choose one from `exact`, `prefix`, `suffix`, `contains`, `regex` | - | Suffix match | -| `contains` | string | No, choose one from `exact`, `prefix`, `suffix`, `contains`, `regex` | - | Contains | -| `regex` | string | No, choose one from `exact`, `prefix`, `suffix`, `contains`, `regex` | - | Regex match | +| `endpoint_mode` | string | No | envoy | Select either `envoy` or `forward_auth` as an optional choice | +| `endpoint` | object | Yes | - | Information about the HTTP service for sending authentication requests | +| `timeout` | int | No | 1000 | Connection timeout for `ext-auth` service, in milliseconds | +| `authorization_request` | object | No | - | Configuration for sending authentication requests | +| `authorization_response` | object | No | - | Configuration for processing authentication responses | + +### Configuration Fields for Each Item in `endpoint` +| Name | Data Type | Required | Default Value | Description | +| ---------------- | --------- | ---------------------- | ------------- | ------------------------------------------------------------------------------------------------------------- | +| `service_name` | string | Required | - | Input the name of the authorization service, in complete FQDN format, e.g., `ext-auth.dns` or `ext-auth.my-ns.svc.cluster.local` | +| `service_port` | int | No | 80 | Input the port of the authorization service | +| `service_host` | string | No | - | The Host header set when requesting the authorization service; remains the same as FQDN if not filled | +| `path_prefix` | string | Required when `endpoint_mode` is `envoy` | | Request path prefix for the client when sending requests to the authorization service | +| `request_method` | string | No | GET | HTTP Method for client requests to the authorization service when `endpoint_mode` is `forward_auth` | +| `path` | string | Required when `endpoint_mode` is `forward_auth` | - | Request path for the client when sending requests to the authorization service | + +### Configuration Fields for Each Item in `authorization_request` +| Name | Data Type | Required | Default Value | Description | +| ------------------------ | ---------------------- | -------- | ------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `allowed_headers` | array of StringMatcher | No | - | When set, client request headers with matching criteria will be added to the headers of the request to the authorization service. The `Authorization` HTTP header will be automatically included in the authorization service request, and if `endpoint_mode` is `forward_auth`, the original request path will be set to `X-Original-Uri` and the original request HTTP method will be set to `X-Original-Method`. | +| `headers_to_add` | `map[string]string` | No | - | Sets the list of request headers to include in the authorization service request. Note that headers with the same name from the client will be overwritten. | +| `with_request_body` | bool | No | false | Buffer the client request body and send it in the authentication request (does not take effect for HTTP Methods GET, OPTIONS, and HEAD) | +| `max_request_body_bytes` | int | No | 10MB | Sets the maximum size of the client request body to keep in memory. When the client request body reaches the value set in this field, an HTTP 413 status code will be returned, and the authorization process will not start. Note that this setting takes precedence over the `failure_mode_allow` configuration. | + +### Configuration Fields for Each Item in `authorization_response` +| Name | Data Type | Required | Default Value | Description | +| -------------------------- | ---------------------- | -------- | ------------- | ----------------------------------------------------------------------------------------------- | +| `allowed_upstream_headers` | array of StringMatcher | No | - | When set, the response headers of the authorization request with matching criteria will be added to the original client request headers. Note that headers with the same name will be overwritten. | +| `allowed_client_headers` | array of StringMatcher | No | - | If not set, all response headers from authorization requests will be added to the client’s response when a request is denied. When set, response headers from authorization requests with matching criteria will be added to the client's response when a request is denied. | + +### Field Descriptions for `StringMatcher` Type +When using `array of StringMatcher`, the fields are configured according to the order defined in the array. +| Name | Data Type | Required | Default Value | Description | +| ---------- | --------- | --------------------------------------------------- | ------------- | ----------- | +| `exact` | string | No, must select one from `exact`, `prefix`, `suffix`, `contains`, `regex` | - | Exact match | +| `prefix` | string | No, must select one from `exact`, `prefix`, `suffix`, `contains`, `regex` | - | Prefix match | +| `suffix` | string | No, must select one from `exact`, `prefix`, `suffix`, `contains`, `regex` | - | Suffix match | +| `contains` | string | No, must select one from `exact`, `prefix`, `suffix`, `contains`, `regex` | - | Contains match | +| `regex` | string | No, must select one from `exact`, `prefix`, `suffix`, `contains`, `regex` | - | Regex match | ## Configuration Example -Assuming the `ext-auth` service in Kubernetes has a serviceName of `ext-auth`, port `8090`, path of `/auth`, and namespace of `backend`, it supports two types of `endpoint_mode`: +Assuming the `ext-auth` service has a serviceName of `ext-auth`, port `8090`, path `/auth`, and namespace `backend` in Kubernetes. -- When `endpoint_mode` is `envoy`, the authorization request will use the original HTTP Method and the configured `path_prefix` as a prefix combined with the original request path. -- When `endpoint_mode` is `forward_auth`, the authorization request will use the configured `request_method` as the HTTP Method and the configured `path` as the request path. +Two types of `endpoint_mode` are supported: +- When `endpoint_mode` is `envoy`, the authentication request will use the original request HTTP Method, and the configured `path_prefix` will be concatenated with the original request path. +- When `endpoint_mode` is `forward_auth`, the authentication request will use the configured `request_method` as the HTTP Method and the configured `path` as the request path. -### Example for endpoint_mode being envoy -#### Example 1 -Configuration of the `ext-auth` plugin: +### Example 1: `endpoint_mode` is `envoy` +#### Configuration of `ext-auth` Plugin: ```yaml http_service: endpoint_mode: envoy @@ -78,25 +80,25 @@ http_service: timeout: 1000 ``` -Using the following request through the gateway, when the `ext-auth` plugin is enabled: +Using the following request to the gateway, after enabling the `ext-auth` plugin: ```shell curl -X POST http://localhost:8082/users?apikey=9a342114-ba8a-11ec-b1bf-00163e1250b5 -X GET -H "foo: bar" -H "Authorization: xxx" ``` -**Request to `ext-auth` service successful:** -The `ext-auth` service will receive the following authorization request: -```shell +**Successful request to the `ext-auth` service:** +The `ext-auth` service will receive the following authentication request: +``` POST /auth/users?apikey=9a342114-ba8a-11ec-b1bf-00163e1250b5 HTTP/1.1 -Host: ext-auth +Host: ext-auth.backend.svc.cluster.local Authorization: xxx Content-Length: 0 ``` -**Request to `ext-auth` service failed:** -When calling the `ext-auth` service gets a 5xx response, the client will receive an HTTP response code of 403 along with all response headers returned by the `ext-auth` service. +**Failed request to the `ext-auth` service:** +When the `ext-auth` service responds with a 5xx error, the client will receive an HTTP response code of 403 along with all response headers returned by the `ext-auth` service. -If the `ext-auth` service returns response headers like `x-auth-version: 1.0` and `x-auth-failed: true`, these will be passed to the client: -```shell +If the `ext-auth` service returns `x-auth-version: 1.0` and `x-auth-failed: true` headers, these will be conveyed to the client: +``` HTTP/1.1 403 Forbidden x-auth-version: 1.0 x-auth-failed: true @@ -105,10 +107,9 @@ server: istio-envoy content-length: 0 ``` -When `ext-auth` is unreachable or returns a status code of 5xx, the client request will be denied with the status code configured in `status_on_error`. If the `ext-auth` service returns other HTTP status codes, the client request will be denied with the returned status code. If `allowed_client_headers` is configured, response headers with corresponding matching items will be added to the client response. +When the `ext-auth` service is inaccessible or returns a status code of 5xx, the client request will be denied with the status code configured in `status_on_error`. When the `ext-auth` service returns other HTTP status codes, the client request will be denied with the returned status code. If `allowed_client_headers` is configured, the matching response headers will be added to the client's response. -#### Example 2 -Configuration of the `ext-auth` plugin: +#### Example 2: `ext-auth` Plugin Configuration: ```yaml http_service: authorization_request: @@ -123,31 +124,31 @@ http_service: endpoint_mode: envoy endpoint: service_name: ext-auth.backend.svc.cluster.local + service_host: my-domain.local service_port: 8090 path_prefix: /auth timeout: 1000 ``` -Using the following request through the gateway, when the `ext-auth` plugin is enabled: +Using the following request to the gateway after enabling the `ext-auth` plugin: ```shell curl -X POST http://localhost:8082/users?apikey=9a342114-ba8a-11ec-b1bf-00163e1250b5 -X GET -H "foo: bar" -H "Authorization: xxx" ``` -The `ext-auth` service will receive the following authorization request: -```shell +The `ext-auth` service will receive the following authentication request: +``` POST /auth/users?apikey=9a342114-ba8a-11ec-b1bf-00163e1250b5 HTTP/1.1 -Host: ext-auth +Host: my-domain.local Authorization: xxx X-Auth-Version: 1.0 x-envoy-header: true Content-Length: 0 ``` -If the response headers from the `ext-auth` service contain `x-user-id` and `x-auth-version`, these two headers will be included in the upstream request when the gateway calls upstream. +If the `ext-auth` service returns headers containing `x-user-id` and `x-auth-version`, these two request headers will be included in requests to the upstream when the gateway calls it. -### Example for endpoint_mode being forward_auth -#### Example 1 -Configuration of the `ext-auth` plugin: +### Example 1: `endpoint_mode` is `forward_auth` +`ext-auth` Plugin Configuration: ```yaml http_service: endpoint_mode: forward_auth @@ -159,27 +160,27 @@ http_service: timeout: 1000 ``` -Using the following request through the gateway, when the `ext-auth` plugin is enabled: +Using the following request to the gateway after enabling the `ext-auth` plugin: ```shell curl -i http://localhost:8082/users?apikey=9a342114-ba8a-11ec-b1bf-00163e1250b5 -X GET -H "foo: bar" -H "Authorization: xxx" ``` -**Request to `ext-auth` service successful:** -The `ext-auth` service will receive the following authorization request: -```shell +**Successful request to the `ext-auth` service:** +The `ext-auth` service will receive the following authentication request: +``` POST /auth HTTP/1.1 -Host: ext-auth +Host: ext-auth.backend.svc.cluster.local Authorization: xxx X-Original-Uri: /users?apikey=9a342114-ba8a-11ec-b1bf-00163e1250b5 X-Original-Method: GET Content-Length: 0 ``` -**Request to `ext-auth` service failed:** -When calling the `ext-auth` service gets a 5xx response, the client will receive an HTTP response code of 403 along with all response headers returned by the `ext-auth` service. +**Failed request to the `ext-auth` service:** +When the `ext-auth` service responds with a 5xx error, the client will receive an HTTP response code of 403 along with all response headers returned by the `ext-auth` service. -If the `ext-auth` service returns response headers like `x-auth-version: 1.0` and `x-auth-failed: true`, these will be passed to the client: -```shell +If the `ext-auth` service returns `x-auth-version: 1.0` and `x-auth-failed: true` headers, these will be conveyed to the client: +``` HTTP/1.1 403 Forbidden x-auth-version: 1.0 x-auth-failed: true @@ -188,10 +189,9 @@ server: istio-envoy content-length: 0 ``` -When `ext-auth` is unreachable or returns a status code of 5xx, the client request will be denied with the status code configured in `status_on_error`. If the `ext-auth` service returns other HTTP status codes, the client request will be denied with the returned status code. If `allowed_client_headers` is configured, response headers with corresponding matching items will be added to the client response. +When the `ext-auth` service is inaccessible or returns a status code of 5xx, the client request will be denied with the status code configured in `status_on_error`. When the `ext-auth` service returns other HTTP status codes, the client request will be denied with the returned status code. If `allowed_client_headers` is configured, the matching response headers will be added to the client's response. -#### Example 2 -Configuration of the `ext-auth` plugin: +#### Example 2: `ext-auth` Plugin Configuration: ```yaml http_service: authorization_request: @@ -206,21 +206,22 @@ http_service: endpoint_mode: forward_auth endpoint: service_name: ext-auth.backend.svc.cluster.local + service_host: my-domain.local service_port: 8090 path: /auth request_method: POST timeout: 1000 ``` -Using the following request through the gateway, when the `ext-auth` plugin is enabled: +Using the following request to the gateway after enabling the `ext-auth` plugin: ```shell curl -i http://localhost:8082/users?apikey=9a342114-ba8a-11ec-b1bf-00163e1250b5 -X GET -H "foo: bar" -H "Authorization: xxx" -H "X-Auth-Version: 1.0" ``` -The `ext-auth` service will receive the following authorization request: -```shell +The `ext-auth` service will receive the following authentication request: +``` POST /auth HTTP/1.1 -Host: ext-auth +Host: my-domain.local Authorization: xxx X-Original-Uri: /users?apikey=9a342114-ba8a-11ec-b1bf-00163e1250b5 X-Original-Method: GET @@ -229,14 +230,14 @@ x-envoy-header: true Content-Length: 0 ``` -If the response headers from the `ext-auth` service contain `x-user-id` and `x-auth-version`, these two headers will be included in the upstream request when the gateway calls upstream. - -#### x-forwarded-* header -When `endpoint_mode` is `forward_auth`, higress will automatically generate and send the following headers to the authorization service. -| Header | Description | -|--------------------|------------------------------------------| -| x-forwarded-proto | The original request scheme, e.g., http/https | -| x-forwarded-method | The original request method, e.g., get/post/delete/patch | -| x-forwarded-host | The original request host | -| x-forwarded-uri | The original request path, including path parameters, e.g., /v1/app?test=true | -| x-forwarded-for | The original request client IP address | +If the `ext-auth` service returns headers containing `x-user-id` and `x-auth-version`, these two request headers will be included in requests to the upstream when the gateway calls it. + +#### x-forwarded-* Header +When `endpoint_mode` is `forward_auth`, Higress will automatically generate and send the following headers to the authorization service. +| Header | Description | +|--------------------|-----------------------------------------------| +| x-forwarded-proto | The scheme of the original request, e.g., http/https | +| x-forwarded-method | The method of the original request, e.g., get/post/delete/patch | +| x-forwarded-host | The host of the original request | +| x-forwarded-uri | The path of the original request, including path parameters, e.g., /v1/app?test=true | +| x-forwarded-for | The client IP address of the original request | diff --git a/backend/sdk/src/main/resources/plugins/ip-restriction/spec.yaml b/backend/sdk/src/main/resources/plugins/ip-restriction/spec.yaml index 93989836..6d437951 100644 --- a/backend/sdk/src/main/resources/plugins/ip-restriction/spec.yaml +++ b/backend/sdk/src/main/resources/plugins/ip-restriction/spec.yaml @@ -2,7 +2,7 @@ apiVersion: 1.0.0 info: gatewayMinVersion: "" type: oss - category: traffic + category: security name: ip-restriction image: platform_wasm/ip-restriction title: IP Restriction diff --git a/backend/sdk/src/main/resources/plugins/key-auth/spec.yaml b/backend/sdk/src/main/resources/plugins/key-auth/spec.yaml index 32093d61..5346f3a2 100644 --- a/backend/sdk/src/main/resources/plugins/key-auth/spec.yaml +++ b/backend/sdk/src/main/resources/plugins/key-auth/spec.yaml @@ -38,7 +38,7 @@ spec: - consumer1 consumers: type: array - scope: GLOBAL + x-scope: GLOBAL title: 调用方列表 x-title-i18n: en-US: Consumer List @@ -73,7 +73,7 @@ spec: - credential keys: type: array - scope: GLOBAL + x-scope: GLOBAL title: API Key 来源字段名称 x-title-i18n: en-US: API Key Names @@ -86,7 +86,7 @@ spec: - apikey in_query: type: boolean - scope: INSTANCE + x-scope: INSTANCE title: 判断key是否在URL参数中 x-title-i18n: en-US: Judge Key in Query @@ -96,7 +96,7 @@ spec: default: true in_header: type: boolean - scope: INSTANCE + x-scope: INSTANCE title: 判断key是否在HTTP请求头中 x-title-i18n: en-US: Judge Key in Header @@ -114,7 +114,7 @@ spec: properties: global_auth: type: boolean - scope: GLOBAL + x-scope: GLOBAL title: 是否开启全局认证 x-title-i18n: en-US: Enable Global Auth @@ -124,7 +124,7 @@ spec: example: false consumers: type: array - scope: GLOBAL + x-scope: GLOBAL title: 调用方列表 x-title-i18n: en-US: Consumer List @@ -159,7 +159,7 @@ spec: - credential keys: type: array - scope: GLOBAL + x-scope: GLOBAL title: API Key 来源字段名称 x-title-i18n: en-US: API Key Names @@ -172,7 +172,7 @@ spec: - apikey in_query: type: boolean - scope: INSTANCE + x-scope: INSTANCE title: 判断key是否在URL参数中 x-title-i18n: en-US: Judge Key in Query @@ -182,7 +182,7 @@ spec: default: true in_header: type: boolean - scope: INSTANCE + x-scope: INSTANCE title: 判断key是否在HTTP请求头中 x-title-i18n: en-US: Judge Key in Header diff --git a/backend/sdk/src/main/resources/plugins/traffic-tag/spec.yaml b/backend/sdk/src/main/resources/plugins/traffic-tag/spec.yaml index 232d1b5f..3ff0e283 100644 --- a/backend/sdk/src/main/resources/plugins/traffic-tag/spec.yaml +++ b/backend/sdk/src/main/resources/plugins/traffic-tag/spec.yaml @@ -7,10 +7,10 @@ info: # 插件名称 name: traffic-tag # 国际版插件标题 - title: traffic-tag + title: Traffic Tagging x-title-i18n: # 插件标题 - zh-CN: traffic-tag + zh-CN: 流量染色 # 国际版插件简介 description: Mark request traffic by adding specific request headers based on weight or specific request content. x-description-i18n: @@ -22,6 +22,7 @@ info: image: platform_wasm/traffic-tag # 支持的最小网关版本 gatewayMinVersion: "" + iconUrl: https://img.alicdn.com/imgextra/i3/O1CN01bAFa9k1t1gdQcVTH0_!!6000000005842-2-tps-42-42.png spec: # 执行阶段 phase: default diff --git a/backend/sdk/src/test/java/com/alibaba/higress/sdk/service/kubernetes/KubernetesModelConverterTest.java b/backend/sdk/src/test/java/com/alibaba/higress/sdk/service/kubernetes/KubernetesModelConverterTest.java index 16953a1f..4a9c83eb 100644 --- a/backend/sdk/src/test/java/com/alibaba/higress/sdk/service/kubernetes/KubernetesModelConverterTest.java +++ b/backend/sdk/src/test/java/com/alibaba/higress/sdk/service/kubernetes/KubernetesModelConverterTest.java @@ -10,27 +10,34 @@ * an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the * specific language governing permissions and limitations under the License. */ -package com.alibaba.higress.sdk.service.kubernetes; - -import java.util.ArrayList; -import java.util.Arrays; -import java.util.Collections; -import java.util.HashMap; -import java.util.List; -import org.junit.jupiter.api.AfterEach; -import org.junit.jupiter.api.Assertions; -import org.junit.jupiter.api.BeforeEach; -import org.junit.jupiter.api.Test; +package com.alibaba.higress.sdk.service.kubernetes; +import com.alibaba.higress.sdk.constant.CommonKey; import com.alibaba.higress.sdk.constant.KubernetesConstants; +import com.alibaba.higress.sdk.exception.ValidationException; +import com.alibaba.higress.sdk.model.Domain; import com.alibaba.higress.sdk.model.Route; +import com.alibaba.higress.sdk.model.ServiceSource; +import com.alibaba.higress.sdk.model.TlsCertificate; +import com.alibaba.higress.sdk.model.WasmPlugin; +import com.alibaba.higress.sdk.model.WasmPluginInstance; +import com.alibaba.higress.sdk.model.WasmPluginInstanceScope; import com.alibaba.higress.sdk.model.route.CorsConfig; +import com.alibaba.higress.sdk.model.route.ProxyNextUpstreamConfig; +import com.alibaba.higress.sdk.model.route.RewriteConfig; import com.alibaba.higress.sdk.model.route.RoutePredicate; import com.alibaba.higress.sdk.model.route.RoutePredicateTypeEnum; import com.alibaba.higress.sdk.model.route.UpstreamService; import com.alibaba.higress.sdk.service.kubernetes.crd.mcp.V1McpBridge; - +import com.alibaba.higress.sdk.service.kubernetes.crd.mcp.V1McpBridgeSpec; +import com.alibaba.higress.sdk.service.kubernetes.crd.mcp.V1RegistryConfig; +import com.alibaba.higress.sdk.service.kubernetes.crd.wasm.MatchRule; +import com.alibaba.higress.sdk.service.kubernetes.crd.wasm.PluginPhase; +import com.alibaba.higress.sdk.service.kubernetes.crd.wasm.V1alpha1WasmPlugin; +import com.alibaba.higress.sdk.service.kubernetes.crd.wasm.V1alpha1WasmPluginSpec; +import com.alibaba.higress.sdk.util.TypeUtil; +import io.kubernetes.client.openapi.models.V1ConfigMap; import io.kubernetes.client.openapi.models.V1HTTPIngressPath; import io.kubernetes.client.openapi.models.V1HTTPIngressRuleValue; import io.kubernetes.client.openapi.models.V1Ingress; @@ -38,8 +45,21 @@ import io.kubernetes.client.openapi.models.V1IngressRule; import io.kubernetes.client.openapi.models.V1IngressServiceBackend; import io.kubernetes.client.openapi.models.V1IngressSpec; +import io.kubernetes.client.openapi.models.V1IngressTLS; import io.kubernetes.client.openapi.models.V1ObjectMeta; +import io.kubernetes.client.openapi.models.V1Secret; import io.kubernetes.client.openapi.models.V1TypedLocalObjectReference; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collections; +import java.util.HashMap; +import java.util.List; +import java.util.Map; import static org.mockito.Mockito.mock; @@ -344,6 +364,138 @@ public void ingress2RouteTestRegularPathSingleService() { Assertions.assertEquals(expectedRoute, route); } + @Test + void route2IngressTestMultipleDomainsThrowsException() { + Route route = new Route(); + route.setDomains(Collections.singletonList("higress.cn")); + route.setPath(RoutePredicate.builder().matchType("exact").matchValue("/test").build()); + route.setServices(Collections.singletonList(new UpstreamService())); + + Assertions.assertThrows(IllegalArgumentException.class, () -> converter.route2Ingress(route)); + } + + @Test + void route2IngressTestInvalidPathMatchTypeThrowsException() { + Route route = new Route(); + route.setDomains(Collections.singletonList("higress.cn")); + route.setPath(RoutePredicate.builder().matchType("invalid").matchValue("/test").build()); + route.setServices(Collections.singletonList(new UpstreamService())); + + Assertions.assertThrows(IllegalArgumentException.class, () -> converter.route2Ingress(route)); + } + + @Test + void route2IngressTestCustomAnnotationsValidInputSuccess() { + Route route = new Route(); + route.setName("test-route"); + route.setDomains(Collections.singletonList("higress.cn")); + route.setCustomConfigs(Collections.singletonMap("custom.higress.cn", "value")); + + V1Ingress ingress = converter.route2Ingress(route); + + Assertions.assertNotNull(ingress); + Assertions.assertEquals("test-route", ingress.getMetadata().getName()); + Assertions.assertEquals("value", ingress.getMetadata().getAnnotations().get("custom.higress.cn")); + } + + @Test + void route2IngressTestCustomAnnotationsThrowsValidationException() { + Route route = new Route(); + route.setDomains(Collections.singletonList("higress.cn")); + route.setCustomConfigs(Collections.singletonMap("higress.io/enable-proxy-next-upstream", "value")); + + Assertions.assertThrows(ValidationException.class, () -> converter.route2Ingress(route)); + } + + @Test + void route2IngressTestCorsConfigSuccess() { + Route route = new Route(); + route.setName("test-route"); + route.setDomains(Collections.singletonList("higress.cn")); + route.setCors(new CorsConfig(true, // enabled (Boolean) + Collections.singletonList("https://higress.cn"), // allowOrigins (List) + Collections.singletonList("GET"), // allowMethods (List) + Collections.singletonList("Content-Type"), // allowHeaders (List) + Collections.singletonList("Content-Length"), // exposeHeaders (List) + 3600, // maxAge (Integer) + true // allowCredentials (Boolean) + )); + + V1Ingress ingress = converter.route2Ingress(route); + + Assertions.assertNotNull(ingress); + Assertions.assertEquals("test-route", ingress.getMetadata().getName()); + Assertions.assertTrue(Boolean.parseBoolean( + ingress.getMetadata().getAnnotations().get(KubernetesConstants.Annotation.CORS_ENABLED_KEY))); + Assertions.assertEquals("3600", + ingress.getMetadata().getAnnotations().get(KubernetesConstants.Annotation.CORS_MAX_AGE_KEY)); + Assertions.assertTrue(Boolean.parseBoolean( + ingress.getMetadata().getAnnotations().get(KubernetesConstants.Annotation.CORS_ALLOW_CREDENTIALS_KEY))); + Assertions.assertEquals("https://higress.cn", + ingress.getMetadata().getAnnotations().get(KubernetesConstants.Annotation.CORS_ALLOW_ORIGIN_KEY)); + Assertions.assertEquals("Content-Type", + ingress.getMetadata().getAnnotations().get(KubernetesConstants.Annotation.CORS_ALLOW_HEADERS_KEY)); + Assertions.assertEquals("GET", + ingress.getMetadata().getAnnotations().get(KubernetesConstants.Annotation.CORS_ALLOW_METHODS_KEY)); + Assertions.assertEquals("Content-Length", + ingress.getMetadata().getAnnotations().get(KubernetesConstants.Annotation.CORS_EXPOSE_HEADERS_KEY)); + } + + @Test + void route2IngressTestMethodsSuccess() { + Route route = new Route(); + route.setName("test-route"); + route.setDomains(Collections.singletonList("higress.cn")); + route.setMethods(Collections.singletonList("GET")); + + V1Ingress ingress = converter.route2Ingress(route); + + Assertions.assertNotNull(ingress); + Assertions.assertEquals("test-route", ingress.getMetadata().getName()); + Assertions.assertEquals("GET", + ingress.getMetadata().getAnnotations().get(KubernetesConstants.Annotation.METHOD_KEY)); + } + + @Test + void route2IngressTestRewriteConfig() { + Route route = new Route(); + route.setName("test-route"); + route.setDomains(Collections.singletonList("higress.cn")); + route.setRewrite(new RewriteConfig(true, "/new-path", "new-host")); + + V1Ingress ingress = converter.route2Ingress(route); + + Assertions.assertNotNull(ingress); + Assertions.assertEquals("test-route", ingress.getMetadata().getName()); + Assertions.assertTrue(Boolean.parseBoolean( + ingress.getMetadata().getAnnotations().get(KubernetesConstants.Annotation.REWRITE_ENABLED_KEY))); + Assertions.assertEquals("/new-path", + ingress.getMetadata().getAnnotations().get(KubernetesConstants.Annotation.REWRITE_PATH_KEY)); + Assertions.assertEquals("new-host", + ingress.getMetadata().getAnnotations().get(KubernetesConstants.Annotation.UPSTREAM_VHOST_KEY)); + } + + @Test + void route2IngressTestProxyNextUpstreamConfig() { + Route route = new Route(); + route.setName("test-route"); + route.setDomains(Collections.singletonList("higress.cn")); + route.setProxyNextUpstream(new ProxyNextUpstreamConfig(true, 2, 10, new String[] {"$http_4xx"})); + + V1Ingress ingress = converter.route2Ingress(route); + + Assertions.assertNotNull(ingress); + Assertions.assertEquals("test-route", ingress.getMetadata().getName()); + Assertions.assertTrue(Boolean.parseBoolean(ingress.getMetadata().getAnnotations() + .get(KubernetesConstants.Annotation.PROXY_NEXT_UPSTREAM_ENABLED_KEY))); + Assertions.assertEquals("2", + ingress.getMetadata().getAnnotations().get(KubernetesConstants.Annotation.PROXY_NEXT_UPSTREAM_TRIES_KEY)); + Assertions.assertEquals("10", + ingress.getMetadata().getAnnotations().get(KubernetesConstants.Annotation.PROXY_NEXT_UPSTREAM_TIMEOUT_KEY)); + Assertions.assertEquals("$http_4xx", + ingress.getMetadata().getAnnotations().get(KubernetesConstants.Annotation.PROXY_NEXT_UPSTREAM_KEY)); + } + @Test public void route2IngressTestPrefixPathSingleService() { Route route = buildBasicRoute(); @@ -538,6 +690,1073 @@ public void route2IngressTestRegularPathSingleService() { Assertions.assertEquals(expectedIngress, ingress); } + @Test + void domain2ConfigMapTestNormalizeDomainName() { + V1ConfigMap domainConfigMap = new V1ConfigMap(); + V1ObjectMeta metadata = new V1ObjectMeta(); + metadata.setName(converter.domainName2ConfigMapName("domain-name")); + metadata.setResourceVersion("0.0.1"); + Map configMap = new HashMap<>(); + configMap.put(CommonKey.DOMAIN, "domain-name"); + configMap.put(KubernetesConstants.K8S_CERT, "domain-cert"); + configMap.put(KubernetesConstants.K8S_ENABLE_HTTPS, "domain-https"); + domainConfigMap.metadata(metadata); + domainConfigMap.data(configMap); + Domain domain = new Domain(); + domain.setName("domain-name"); + domain.setVersion("0.0.1"); + domain.setCertIdentifier("domain-cert"); + domain.setEnableHttps("domain-https"); + V1ConfigMap target = converter.domain2ConfigMap(domain); + Assertions.assertEquals(domainConfigMap, target); + } + + @Test + void configMap2DomainTestValidConfigMapShouldReturnDomain() { + V1ObjectMeta metadata = new V1ObjectMeta(); + metadata.setResourceVersion("1"); + + Map data = new HashMap<>(); + data.put(CommonKey.DOMAIN, "higress.cn"); + data.put(KubernetesConstants.K8S_CERT, "cert-identifier"); + data.put(KubernetesConstants.K8S_ENABLE_HTTPS, "true"); + + V1ConfigMap configMap = new V1ConfigMap(); + configMap.setMetadata(metadata); + configMap.setData(data); + + Domain domain = converter.configMap2Domain(configMap); + + Assertions.assertNotNull(domain); + Assertions.assertEquals("higress.cn", domain.getName()); + Assertions.assertEquals("1", domain.getVersion()); + Assertions.assertEquals("cert-identifier", domain.getCertIdentifier()); + Assertions.assertEquals("true", domain.getEnableHttps()); + } + + @Test + void configMap2DomainTestNullMetadataShouldReturnDomainWithNullVersion() { + Map data = new HashMap<>(); + data.put(CommonKey.DOMAIN, "higress.cn"); + + V1ConfigMap configMap = new V1ConfigMap(); + configMap.setData(data); + + Domain domain = converter.configMap2Domain(configMap); + + Assertions.assertNotNull(domain); + Assertions.assertEquals("higress.cn", domain.getName()); + Assertions.assertNull(domain.getVersion()); + Assertions.assertNull(domain.getCertIdentifier()); + Assertions.assertNull(domain.getEnableHttps()); + } + + @Test + void configMap2DomainTestNullDataShouldThrowIllegalArgumentException() { + V1ConfigMap configMap = new V1ConfigMap(); + configMap.setData(null); + + IllegalArgumentException exception = Assertions.assertThrows(IllegalArgumentException.class, () -> { + converter.configMap2Domain(configMap); + }); + Assertions.assertEquals("The ConfigMap data is illegal", exception.getMessage()); + } + + @Test + void configMap2DomainTestMissingFieldsShouldReturnDomainWithNullFields() { + Map data = new HashMap<>(); + data.put(CommonKey.DOMAIN, "higress.cn"); + + V1ConfigMap configMap = new V1ConfigMap(); + configMap.setData(data); + + Domain domain = converter.configMap2Domain(configMap); + + Assertions.assertNotNull(domain); + Assertions.assertEquals("higress.cn", domain.getName()); + Assertions.assertNull(domain.getCertIdentifier()); + Assertions.assertNull(domain.getEnableHttps()); + } + + @Test + void tlsCertificate2SecretTestValidCertificateWithoutDomainsShouldNotSetLabels() { + TlsCertificate certificate = + TlsCertificate.builder().cert("dummyCert").key("dummyKey").name("test-certificate").version("1") + .domains(Collections.emptyList()).build(); + + V1Secret secret = converter.tlsCertificate2Secret(certificate); + + Assertions.assertNotNull(secret); + Assertions.assertEquals(KubernetesConstants.SECRET_TYPE_TLS, secret.getType()); + Assertions.assertEquals("test-certificate", secret.getMetadata().getName()); + Assertions.assertEquals("1", secret.getMetadata().getResourceVersion()); + + Map data = secret.getData(); + Assertions.assertNotNull(data); + Assertions.assertEquals(2, data.size()); + Assertions.assertArrayEquals(TypeUtil.string2Bytes("dummyCert"), + data.get(KubernetesConstants.SECRET_TLS_CRT_FIELD)); + Assertions.assertArrayEquals(TypeUtil.string2Bytes("dummyKey"), + data.get(KubernetesConstants.SECRET_TLS_KEY_FIELD)); + + Map labels = secret.getMetadata().getLabels(); + Assertions.assertNull(labels); + } + + @Test + void secret2TlsCertificateTestValidSecretWithoutCertAndKeyShouldReturnTlsCertificateWithoutCertAndKey() { + V1Secret secret = new V1Secret(); + secret.setMetadata(new V1ObjectMeta() {{ + setName("test-secret"); + setResourceVersion("123"); + }}); + + TlsCertificate tlsCertificate = converter.secret2TlsCertificate(secret); + + Assertions.assertNotNull(tlsCertificate); + Assertions.assertEquals("test-secret", tlsCertificate.getName()); + Assertions.assertEquals("123", tlsCertificate.getVersion()); + Assertions.assertNull(tlsCertificate.getCert()); + Assertions.assertNull(tlsCertificate.getKey()); + Assertions.assertNull(tlsCertificate.getValidityStart()); + Assertions.assertNull(tlsCertificate.getValidityEnd()); + Assertions.assertNull(tlsCertificate.getDomains()); + } + + @Test + void secret2TlsCertificateTestNullMetadataShouldReturnTlsCertificateWithoutNameAndVersion() { + V1Secret secret = new V1Secret(); + secret.setData(new HashMap() {{ + put(KubernetesConstants.SECRET_TLS_CRT_FIELD, "certData".getBytes()); + put(KubernetesConstants.SECRET_TLS_KEY_FIELD, "keyData".getBytes()); + }}); + + TlsCertificate tlsCertificate = converter.secret2TlsCertificate(secret); + + Assertions.assertNotNull(tlsCertificate); + Assertions.assertNull(tlsCertificate.getName()); + Assertions.assertNull(tlsCertificate.getVersion()); + Assertions.assertEquals("certData", tlsCertificate.getCert()); + Assertions.assertEquals("keyData", tlsCertificate.getKey()); + } + + @Test + void secret2TlsCertificateTestEmptyDataShouldReturnTlsCertificateWithoutCertAndKey() { + V1Secret secret = new V1Secret(); + secret.setMetadata(new V1ObjectMeta() {{ + setName("test-secret"); + setResourceVersion("123"); + }}); + secret.setData(Collections.emptyMap()); + + TlsCertificate tlsCertificate = converter.secret2TlsCertificate(secret); + + Assertions.assertNotNull(tlsCertificate); + Assertions.assertEquals("test-secret", tlsCertificate.getName()); + Assertions.assertEquals("123", tlsCertificate.getVersion()); + Assertions.assertNull(tlsCertificate.getCert()); + Assertions.assertNull(tlsCertificate.getKey()); + Assertions.assertNull(tlsCertificate.getValidityStart()); + Assertions.assertNull(tlsCertificate.getValidityEnd()); + Assertions.assertNull(tlsCertificate.getDomains()); + } + + @Test + void wasmPluginFromCrTestWithValidInputShouldReturnWasmPlugin() { + V1alpha1WasmPlugin cr = new V1alpha1WasmPlugin(); + cr.setMetadata(createMetadata()); + cr.setSpec(createSpec()); + + WasmPlugin plugin = converter.wasmPluginFromCr(cr); + + Assertions.assertNotNull(plugin); + Assertions.assertEquals("test-plugin", plugin.getName()); + Assertions.assertEquals("v1", plugin.getPluginVersion()); + Assertions.assertEquals("test-category", plugin.getCategory()); + Assertions.assertEquals(Boolean.TRUE, plugin.getBuiltIn()); + Assertions.assertEquals("Test Plugin", plugin.getTitle()); + Assertions.assertEquals("A test plugin", plugin.getDescription()); + Assertions.assertEquals("icon.png", plugin.getIcon()); + Assertions.assertEquals("test/image", plugin.getImageRepository()); + Assertions.assertEquals("v1", plugin.getImageVersion()); + Assertions.assertEquals(PluginPhase.UNSPECIFIED.getName(), plugin.getPhase()); + Assertions.assertEquals(10, plugin.getPriority()); + } + + @Test + void wasmPluginFromCrTestWithMissingFieldsShouldHandleGracefully() { + V1alpha1WasmPlugin cr = new V1alpha1WasmPlugin(); + cr.setMetadata(createMetadata()); + cr.setSpec(createSpec()); + + cr.getMetadata().getLabels().remove(KubernetesConstants.Label.WASM_PLUGIN_NAME_KEY); + cr.getMetadata().getLabels().remove(KubernetesConstants.Label.WASM_PLUGIN_VERSION_KEY); + cr.getSpec().setUrl("image"); + + WasmPlugin plugin = converter.wasmPluginFromCr(cr); + + Assertions.assertNotNull(plugin); + Assertions.assertEquals(null, plugin.getName()); + Assertions.assertEquals(null, plugin.getPluginVersion()); + Assertions.assertEquals("test-category", plugin.getCategory()); + Assertions.assertEquals(Boolean.TRUE, plugin.getBuiltIn()); + Assertions.assertEquals("Test Plugin", plugin.getTitle()); + Assertions.assertEquals("A test plugin", plugin.getDescription()); + Assertions.assertEquals("icon.png", plugin.getIcon()); + Assertions.assertEquals("image", plugin.getImageRepository()); + Assertions.assertEquals(null, plugin.getImageVersion()); + Assertions.assertEquals(PluginPhase.UNSPECIFIED.getName(), plugin.getPhase()); + Assertions.assertEquals(10, plugin.getPriority()); + } + + @Test + void wasmPluginToCrTestValidInput_ShouldConvertCorrectly() { + WasmPlugin plugin = + WasmPlugin.builder().name("test-plugin").pluginVersion("1.0.0").version("1").category("test-category") + .title("Test Plugin").description("A test plugin").icon("test-icon").builtIn(true) + .imageRepository("test-repository").imageVersion("test-version").phase("test-phase").priority(10) + .build(); + + V1alpha1WasmPlugin cr = converter.wasmPluginToCr(plugin); + + Assertions.assertNotNull(cr); + Assertions.assertNotNull(cr.getMetadata()); + Assertions.assertEquals("test-plugin-1.0.0", cr.getMetadata().getName()); + Assertions.assertEquals("1", cr.getMetadata().getResourceVersion()); + + Assertions.assertEquals("test-plugin", + cr.getMetadata().getLabels().get(KubernetesConstants.Label.WASM_PLUGIN_NAME_KEY)); + Assertions.assertEquals("1.0.0", + cr.getMetadata().getLabels().get(KubernetesConstants.Label.WASM_PLUGIN_VERSION_KEY)); + Assertions.assertEquals("test-category", + cr.getMetadata().getLabels().get(KubernetesConstants.Label.WASM_PLUGIN_CATEGORY_KEY)); + Assertions.assertEquals("true", + cr.getMetadata().getLabels().get(KubernetesConstants.Label.WASM_PLUGIN_BUILT_IN_KEY)); + + Assertions.assertEquals("Test Plugin", + cr.getMetadata().getAnnotations().get(KubernetesConstants.Annotation.WASM_PLUGIN_TITLE_KEY)); + Assertions.assertEquals("A test plugin", + cr.getMetadata().getAnnotations().get(KubernetesConstants.Annotation.WASM_PLUGIN_DESCRIPTION_KEY)); + Assertions.assertEquals("test-icon", + cr.getMetadata().getAnnotations().get(KubernetesConstants.Annotation.WASM_PLUGIN_ICON_KEY)); + + Assertions.assertNotNull(cr.getSpec()); + Assertions.assertEquals("test-phase", cr.getSpec().getPhase()); + Assertions.assertEquals(10, cr.getSpec().getPriority().intValue()); + Assertions.assertEquals("oci://test-repository:test-version", cr.getSpec().getUrl()); + } + + @Test + void wasmPluginToCrTestNullImageRepositoryShouldHandleCorrectly() { + WasmPlugin plugin = + WasmPlugin.builder().name("test-plugin").pluginVersion("1.0.0").version("1").imageRepository(null) + .imageVersion("test-version").build(); + + V1alpha1WasmPlugin cr = converter.wasmPluginToCr(plugin); + + Assertions.assertNotNull(cr); + Assertions.assertNotNull(cr.getSpec()); + Assertions.assertEquals(null, cr.getSpec().getUrl()); + } + + @Test + void wasmPluginToCrTestEmptyImageVersionShouldHandleCorrectly() { + WasmPlugin plugin = WasmPlugin.builder().name("test-plugin").pluginVersion("1.0.0").version("1") + .imageRepository("test-repository").imageVersion("").build(); + + V1alpha1WasmPlugin cr = converter.wasmPluginToCr(plugin); + + Assertions.assertNotNull(cr); + Assertions.assertNotNull(cr.getSpec()); + Assertions.assertEquals("oci://test-repository", cr.getSpec().getUrl()); + } + + @Test + void mergeWasmPluginSpecTestSourceSpecNullDestinationSpecUnchanged() { + V1alpha1WasmPlugin srcPlugin = new V1alpha1WasmPlugin(); + srcPlugin.setSpec(null); + + V1alpha1WasmPlugin dstPlugin = new V1alpha1WasmPlugin(); + V1alpha1WasmPluginSpec dstSpec = new V1alpha1WasmPluginSpec(); + dstSpec.setDefaultConfig(new HashMap<>()); + dstPlugin.setSpec(dstSpec); + + converter.mergeWasmPluginSpec(srcPlugin, dstPlugin); + + Assertions.assertEquals(new HashMap<>(), dstPlugin.getSpec().getDefaultConfig()); + } + + @Test + void mergeWasmPluginSpecTestDestinationSpecNullSetNewSpec() { + V1alpha1WasmPlugin srcPlugin = new V1alpha1WasmPlugin(); + V1alpha1WasmPluginSpec srcSpec = new V1alpha1WasmPluginSpec(); + srcSpec.setDefaultConfig(new HashMap<>()); + srcPlugin.setSpec(srcSpec); + + V1alpha1WasmPlugin dstPlugin = new V1alpha1WasmPlugin(); + dstPlugin.setSpec(null); + + converter.mergeWasmPluginSpec(srcPlugin, dstPlugin); + + Assertions.assertEquals(new HashMap<>(), dstPlugin.getSpec().getDefaultConfig()); + } + + @Test + void mergeWasmPluginSpecTestMatchRulesMergedAndSorted() { + V1alpha1WasmPlugin srcPlugin = new V1alpha1WasmPlugin(); + V1alpha1WasmPluginSpec srcSpec = new V1alpha1WasmPluginSpec(); + srcSpec.setMatchRules(new ArrayList<>()); + srcSpec.getMatchRules().add(MatchRule.forDomain("higress.cn")); + srcSpec.getMatchRules().add(MatchRule.forDomain("test.higress.cn")); + srcPlugin.setSpec(srcSpec); + + V1alpha1WasmPlugin dstPlugin = new V1alpha1WasmPlugin(); + V1alpha1WasmPluginSpec dstSpec = new V1alpha1WasmPluginSpec(); + dstSpec.setMatchRules(new ArrayList<>()); + dstSpec.getMatchRules().add(MatchRule.forDomain("higress.cn")); + dstSpec.getMatchRules().add(MatchRule.forDomain("test.higress.cn")); + dstPlugin.setSpec(dstSpec); + + converter.mergeWasmPluginSpec(srcPlugin, dstPlugin); + + List expected = new ArrayList<>(); + expected.add(MatchRule.forDomain("higress.cn")); + expected.add(MatchRule.forDomain("test.higress.cn")); + + Assertions.assertEquals(expected, dstPlugin.getSpec().getMatchRules()); + } + + @Test + void mergeWasmPluginSpecTestEmptyMatchRulesNoChange() { + V1alpha1WasmPlugin srcPlugin = new V1alpha1WasmPlugin(); + V1alpha1WasmPluginSpec srcSpec = new V1alpha1WasmPluginSpec(); + srcSpec.setMatchRules(new ArrayList<>()); + srcPlugin.setSpec(srcSpec); + + V1alpha1WasmPlugin dstPlugin = new V1alpha1WasmPlugin(); + V1alpha1WasmPluginSpec dstSpec = new V1alpha1WasmPluginSpec(); + dstSpec.setMatchRules(new ArrayList<>()); + dstSpec.getMatchRules().add(MatchRule.forDomain("higress.cn")); + dstPlugin.setSpec(dstSpec); + + converter.mergeWasmPluginSpec(srcPlugin, dstPlugin); + + List expected = new ArrayList<>(); + expected.add(MatchRule.forDomain("higress.cn")); + + Assertions.assertEquals(expected, dstPlugin.getSpec().getMatchRules()); + } + + @Test + void mergeWasmPluginSpecTestSrcSpecNullNoChangeToDstPlugin() { + V1alpha1WasmPlugin srcPlugin = new V1alpha1WasmPlugin(); + srcPlugin.setSpec(null); + + V1alpha1WasmPlugin dstPlugin = new V1alpha1WasmPlugin(); + V1alpha1WasmPluginSpec dstSpec = new V1alpha1WasmPluginSpec(); + dstSpec.setDefaultConfig(new HashMap<>()); + dstPlugin.setSpec(dstSpec); + + converter.mergeWasmPluginSpec(srcPlugin, dstPlugin); + + Assertions.assertEquals(new HashMap<>(), dstPlugin.getSpec().getDefaultConfig()); + } + + @Test + void mergeWasmPluginSpecTestDstSpecNullSetNewSpec() { + V1alpha1WasmPlugin srcPlugin = new V1alpha1WasmPlugin(); + V1alpha1WasmPluginSpec srcSpec = new V1alpha1WasmPluginSpec(); + srcSpec.setDefaultConfig(new HashMap<>()); + srcPlugin.setSpec(srcSpec); + + V1alpha1WasmPlugin dstPlugin = new V1alpha1WasmPlugin(); + dstPlugin.setSpec(null); + + converter.mergeWasmPluginSpec(srcPlugin, dstPlugin); + + Assertions.assertEquals(new HashMap<>(), dstPlugin.getSpec().getDefaultConfig()); + } + + @Test + void mergeWasmPluginSpecTestMatchRulesMergedAndSortedWithExistingRules() { + V1alpha1WasmPlugin srcPlugin = new V1alpha1WasmPlugin(); + V1alpha1WasmPluginSpec srcSpec = new V1alpha1WasmPluginSpec(); + srcSpec.setMatchRules(new ArrayList<>()); + srcSpec.getMatchRules().add(MatchRule.forDomain("higress.cn")); + srcSpec.getMatchRules().add(MatchRule.forDomain("test.higress.cn")); + srcPlugin.setSpec(srcSpec); + + V1alpha1WasmPlugin dstPlugin = new V1alpha1WasmPlugin(); + V1alpha1WasmPluginSpec dstSpec = new V1alpha1WasmPluginSpec(); + dstSpec.setMatchRules(new ArrayList<>()); + dstSpec.getMatchRules().add(MatchRule.forDomain("higress.cn")); + dstPlugin.setSpec(dstSpec); + + converter.mergeWasmPluginSpec(srcPlugin, dstPlugin); + + List expected = new ArrayList<>(); + expected.add(MatchRule.forDomain("higress.cn")); + expected.add(MatchRule.forDomain("test.higress.cn")); + + Assertions.assertEquals(expected, dstPlugin.getSpec().getMatchRules()); + } + + @Test + void setWasmPluginInstanceToCrTestGlobalScopeConfigured() { + V1alpha1WasmPlugin plugin = new V1alpha1WasmPlugin(); + plugin.setMetadata(createMetadata("global", "test-plugin", "v1")); + plugin.setSpec(createSpecWithGlobalConfig()); + + WasmPluginInstance instance = + converter.getWasmPluginInstanceFromCr(plugin, WasmPluginInstanceScope.GLOBAL, null); + Assertions.assertNotNull(instance); + Assertions.assertEquals("v1", instance.getPluginVersion()); + Assertions.assertEquals(WasmPluginInstanceScope.GLOBAL, instance.getScope()); + Assertions.assertTrue(instance.getEnabled()); + Assertions.assertEquals(1, instance.getConfigurations().size()); + Assertions.assertEquals("value", instance.getConfigurations().get("key")); + } + + @Test + void setWasmPluginInstanceToCrTestDomainScopeNotConfigured() { + V1alpha1WasmPlugin plugin = new V1alpha1WasmPlugin(); + plugin.setMetadata(createMetadata("domain", "test-plugin", "v1")); + plugin.setSpec(createSpecWithDomainConfig("higress.cn")); + + WasmPluginInstance instance = + converter.getWasmPluginInstanceFromCr(plugin, WasmPluginInstanceScope.DOMAIN, "higress.cn"); + Assertions.assertNotNull(instance); + Assertions.assertEquals("v1", instance.getPluginVersion()); + Assertions.assertEquals(WasmPluginInstanceScope.DOMAIN, instance.getScope()); + Assertions.assertTrue(instance.getEnabled()); + Assertions.assertEquals(1, instance.getConfigurations().size()); + Assertions.assertEquals("value", instance.getConfigurations().get("key")); + + WasmPluginInstance instanceNotConfigured = + converter.getWasmPluginInstanceFromCr(plugin, WasmPluginInstanceScope.DOMAIN, "nonexistent.com"); + Assertions.assertNull(instanceNotConfigured); + } + + @Test + void setWasmPluginInstanceToCrTestRouteScopeConfigured() { + V1alpha1WasmPlugin plugin = new V1alpha1WasmPlugin(); + plugin.setMetadata(createMetadata("route", "test-plugin", "v1")); + plugin.setSpec(createSpecWithRouteConfig("test-route")); + + WasmPluginInstance instance = + converter.getWasmPluginInstanceFromCr(plugin, WasmPluginInstanceScope.ROUTE, "test-route"); + Assertions.assertNotNull(instance); + Assertions.assertEquals("v1", instance.getPluginVersion()); + Assertions.assertEquals(WasmPluginInstanceScope.ROUTE, instance.getScope()); + Assertions.assertTrue(instance.getEnabled()); + Assertions.assertEquals(1, instance.getConfigurations().size()); + Assertions.assertEquals("value", instance.getConfigurations().get("key")); + } + + @Test + void setWasmPluginInstanceToCrTestGlobalScopeShouldSetDefaultConfig() { + V1alpha1WasmPlugin cr = new V1alpha1WasmPlugin(); + WasmPluginInstance instance = + WasmPluginInstance.builder().scope(WasmPluginInstanceScope.GLOBAL).target(null).enabled(true) + .configurations(Map.of("key", "value")).build(); + + converter.setWasmPluginInstanceToCr(cr, instance); + + V1alpha1WasmPluginSpec spec = cr.getSpec(); + Assertions.assertNotNull(spec); + Assertions.assertEquals(Map.of("key", "value"), spec.getDefaultConfig()); + Assertions.assertFalse(spec.getDefaultConfigDisable()); + } + + @Test + void setWasmPluginInstanceToCrTestDomainScopeShouldAddOrUpdateDomainRule() { + V1alpha1WasmPlugin cr = new V1alpha1WasmPlugin(); + WasmPluginInstance instance = + WasmPluginInstance.builder().scope(WasmPluginInstanceScope.DOMAIN).target("higress.cn").enabled(true) + .configurations(Map.of("key", "value")).build(); + + converter.setWasmPluginInstanceToCr(cr, instance); + + V1alpha1WasmPluginSpec spec = cr.getSpec(); + Assertions.assertNotNull(spec); + List matchRules = spec.getMatchRules(); + Assertions.assertNotNull(matchRules); + Assertions.assertEquals(1, matchRules.size()); + MatchRule domainRule = matchRules.get(0); + Assertions.assertTrue(domainRule.getDomain().contains("higress.cn")); + Assertions.assertEquals(Map.of("key", "value"), domainRule.getConfig()); + Assertions.assertFalse(domainRule.getConfigDisable()); + } + + @Test + void setWasmPluginInstanceToCrTestDomainScopeExistingRuleShouldUpdateExistingDomainRule() { + V1alpha1WasmPlugin cr = new V1alpha1WasmPlugin(); + V1alpha1WasmPluginSpec spec = new V1alpha1WasmPluginSpec(); + spec.setMatchRules(List.of(new MatchRule(false, Map.of("key", "original"), List.of("higress.cn"), List.of()))); + cr.setSpec(spec); + + WasmPluginInstance instance = + WasmPluginInstance.builder().scope(WasmPluginInstanceScope.DOMAIN).target("higress.cn").enabled(true) + .configurations(Map.of("key", "updated")).build(); + + converter.setWasmPluginInstanceToCr(cr, instance); + + List matchRules = cr.getSpec().getMatchRules(); + Assertions.assertNotNull(matchRules); + Assertions.assertEquals(1, matchRules.size()); + MatchRule domainRule = matchRules.get(0); + Assertions.assertTrue(domainRule.getDomain().contains("higress.cn")); + Assertions.assertEquals(Map.of("key", "updated"), domainRule.getConfig()); + Assertions.assertFalse(domainRule.getConfigDisable()); + } + + @Test + void setWasmPluginInstanceToCrTestRouteScopeShouldAddOrUpdateRouteRule() { + V1alpha1WasmPlugin cr = new V1alpha1WasmPlugin(); + WasmPluginInstance instance = + WasmPluginInstance.builder().scope(WasmPluginInstanceScope.ROUTE).target("route-1").enabled(true) + .configurations(Map.of("key", "value")).build(); + + converter.setWasmPluginInstanceToCr(cr, instance); + + V1alpha1WasmPluginSpec spec = cr.getSpec(); + Assertions.assertNotNull(spec); + List matchRules = spec.getMatchRules(); + Assertions.assertNotNull(matchRules); + Assertions.assertEquals(1, matchRules.size()); + MatchRule routeRule = matchRules.get(0); + Assertions.assertTrue(routeRule.getIngress().contains("route-1")); + Assertions.assertEquals(Map.of("key", "value"), routeRule.getConfig()); + Assertions.assertFalse(routeRule.getConfigDisable()); + } + + @Test + void removeWasmPluginInstanceFromCrTestGlobalScopeTargetNullShouldRemoveDefaultConfig() { + V1alpha1WasmPlugin cr = new V1alpha1WasmPlugin(); + V1alpha1WasmPluginSpec spec = new V1alpha1WasmPluginSpec(); + Map config = new HashMap<>(); + config.put("key", "value"); + spec.setDefaultConfig(config); + cr.setSpec(spec); + boolean result = converter.removeWasmPluginInstanceFromCr(cr, WasmPluginInstanceScope.GLOBAL, null); + + Assertions.assertTrue(result); + Assertions.assertNull(cr.getSpec().getDefaultConfig()); + } + + @Test + void removeWasmPluginInstanceFromCrTestDomainScopeValidTargetShouldRemoveDomain() { + V1alpha1WasmPlugin cr = new V1alpha1WasmPlugin(); + V1alpha1WasmPluginSpec spec = new V1alpha1WasmPluginSpec(); + List matchRules = new ArrayList<>(); + MatchRule rule = new MatchRule(); + rule.setDomain(new ArrayList() {{ + add("higress.cn"); + }}); + matchRules.add(rule); + spec.setMatchRules(matchRules); + cr.setSpec(spec); + + boolean result = converter.removeWasmPluginInstanceFromCr(cr, WasmPluginInstanceScope.DOMAIN, "higress.cn"); + + Assertions.assertTrue(result); + Assertions.assertTrue(cr.getSpec().getMatchRules().isEmpty()); + } + + @Test + void removeWasmPluginInstanceFromCrTestDomainScopeEmptyTargetShouldNotChange() { + V1alpha1WasmPlugin cr = new V1alpha1WasmPlugin(); + V1alpha1WasmPluginSpec spec = new V1alpha1WasmPluginSpec(); + List matchRules = new ArrayList<>(); + MatchRule rule = new MatchRule(); + rule.setDomain(new ArrayList() {{ + add("higress.cn"); + }}); + matchRules.add(rule); + spec.setMatchRules(matchRules); + cr.setSpec(spec); + + boolean result = converter.removeWasmPluginInstanceFromCr(cr, WasmPluginInstanceScope.DOMAIN, ""); + + Assertions.assertFalse(result); + Assertions.assertEquals(1, cr.getSpec().getMatchRules().size()); + } + + @Test + void removeWasmPluginInstanceFromCrTestRouteScopeValidTargetShouldRemoveIngress() { + V1alpha1WasmPlugin cr = new V1alpha1WasmPlugin(); + V1alpha1WasmPluginSpec spec = new V1alpha1WasmPluginSpec(); + List matchRules = new ArrayList<>(); + MatchRule rule = new MatchRule(); + rule.setIngress(new ArrayList() {{ + add("test-route"); + }}); + matchRules.add(rule); + spec.setMatchRules(matchRules); + cr.setSpec(spec); + + boolean result = converter.removeWasmPluginInstanceFromCr(cr, WasmPluginInstanceScope.ROUTE, "test-route"); + + Assertions.assertTrue(result); + Assertions.assertTrue(cr.getSpec().getMatchRules().isEmpty()); + } + + @Test + void removeWasmPluginInstanceFromCrTestRouteScopeEmptyTargetShouldNotChange() { + V1alpha1WasmPlugin cr = new V1alpha1WasmPlugin(); + V1alpha1WasmPluginSpec spec = new V1alpha1WasmPluginSpec(); + List matchRules = new ArrayList<>(); + MatchRule rule = new MatchRule(); + rule.setIngress(new ArrayList() {{ + add("test-route"); + }}); + matchRules.add(rule); + spec.setMatchRules(matchRules); + cr.setSpec(spec); + + boolean result = converter.removeWasmPluginInstanceFromCr(cr, WasmPluginInstanceScope.ROUTE, ""); + + Assertions.assertFalse(result); + Assertions.assertEquals(1, cr.getSpec().getMatchRules().size()); + } + + @Test + public void v1RegistryConfig2ServiceSourceTestNacosType() { + V1RegistryConfig v1RegistryConfig = new V1RegistryConfig(); + v1RegistryConfig.setType(V1McpBridge.REGISTRY_TYPE_NACOS); + v1RegistryConfig.setDomain("testDomain"); + v1RegistryConfig.setPort(80); + v1RegistryConfig.setName("testName"); + v1RegistryConfig.setNacosNamespaceId("testNamespaceId"); + v1RegistryConfig.setNacosGroups(List.of("testGroup1", "testGroup2")); + + ServiceSource serviceSource = converter.v1RegistryConfig2ServiceSource(v1RegistryConfig); + + Assertions.assertNotNull(serviceSource); + Assertions.assertEquals(V1McpBridge.REGISTRY_TYPE_NACOS, serviceSource.getType()); + Assertions.assertEquals("testDomain", serviceSource.getDomain()); + Assertions.assertEquals(80, serviceSource.getPort()); + Assertions.assertEquals("testName", serviceSource.getName()); + Map properties = serviceSource.getProperties(); + Assertions.assertNotNull(properties); + Assertions.assertEquals("testNamespaceId", properties.get(V1McpBridge.REGISTRY_TYPE_NACOS_NAMESPACE_ID)); + Assertions.assertEquals(List.of("testGroup1", "testGroup2"), + properties.get(V1McpBridge.REGISTRY_TYPE_NACOS_GROUPS)); + } + + @Test + public void v1RegistryConfig2ServiceSourceTestZkType() { + V1RegistryConfig v1RegistryConfig = new V1RegistryConfig(); + v1RegistryConfig.setType(V1McpBridge.REGISTRY_TYPE_ZK); + v1RegistryConfig.setDomain("testDomain"); + v1RegistryConfig.setPort(80); + v1RegistryConfig.setName("testName"); + v1RegistryConfig.setZkServicesPath(List.of("testPath1", "testPath2")); + + ServiceSource serviceSource = converter.v1RegistryConfig2ServiceSource(v1RegistryConfig); + + Assertions.assertNotNull(serviceSource); + Assertions.assertEquals(V1McpBridge.REGISTRY_TYPE_ZK, serviceSource.getType()); + Assertions.assertEquals("testDomain", serviceSource.getDomain()); + Assertions.assertEquals(80, serviceSource.getPort()); + Assertions.assertEquals("testName", serviceSource.getName()); + Map properties = serviceSource.getProperties(); + Assertions.assertNotNull(properties); + Assertions.assertEquals(List.of("testPath1", "testPath2"), + properties.get(V1McpBridge.REGISTRY_TYPE_ZK_SERVICES_PATH)); + } + + @Test + public void v1RegistryConfig2ServiceSourceTestConsulType() { + V1RegistryConfig v1RegistryConfig = new V1RegistryConfig(); + v1RegistryConfig.setType(V1McpBridge.REGISTRY_TYPE_CONSUL); + v1RegistryConfig.setDomain("testDomain"); + v1RegistryConfig.setPort(80); + v1RegistryConfig.setName("testName"); + v1RegistryConfig.setConsulDataCenter("testDataCenter"); + v1RegistryConfig.setConsulServiceTag("testServiceTag"); + v1RegistryConfig.setConsulRefreshInterval(30); + + ServiceSource serviceSource = converter.v1RegistryConfig2ServiceSource(v1RegistryConfig); + + Assertions.assertNotNull(serviceSource); + Assertions.assertEquals(V1McpBridge.REGISTRY_TYPE_CONSUL, serviceSource.getType()); + Assertions.assertEquals("testDomain", serviceSource.getDomain()); + Assertions.assertEquals(80, serviceSource.getPort()); + Assertions.assertEquals("testName", serviceSource.getName()); + Map properties = serviceSource.getProperties(); + Assertions.assertNotNull(properties); + Assertions.assertEquals("testDataCenter", properties.get(V1McpBridge.REGISTRY_TYPE_CONSUL_DATA_CENTER)); + Assertions.assertEquals("testServiceTag", properties.get(V1McpBridge.REGISTRY_TYPE_CONSUL_SERVICE_TAG)); + Assertions.assertEquals(30, properties.get(V1McpBridge.REGISTRY_TYPE_CONSUL_REFRESH_INTERVAL)); + } + + @Test + public void v1RegistryConfig2ServiceSourceTestNullInput() { + ServiceSource serviceSource = converter.v1RegistryConfig2ServiceSource(null); + + Assertions.assertNotNull(serviceSource); + Assertions.assertEquals(new ServiceSource(), serviceSource); + } + + @Test + public void generateAuthSecretNameTestValidServiceSourceName() { + String serviceSourceName = "test-service-source"; + String expectedPattern = serviceSourceName + "-auth-\\w{5}"; + + String authSecretName = converter.generateAuthSecretName(serviceSourceName); + + Assertions.assertTrue(authSecretName.matches(expectedPattern), + "Auth secret name should match the expected pattern"); + } + + @Test + public void generateAuthSecretNameTestEmptyServiceSourceName() { + String serviceSourceName = ""; + String expectedPattern = serviceSourceName + "-auth-\\w{5}"; + + String authSecretName = converter.generateAuthSecretName(serviceSourceName); + + Assertions.assertTrue(authSecretName.matches(expectedPattern), + "Auth secret name should match the expected pattern"); + } + + @Test + public void generateAuthSecretNameTestNullServiceSourceName() { + String serviceSourceName = null; + String expectedPattern = "null-auth-\\w{5}"; + + String authSecretName = converter.generateAuthSecretName(serviceSourceName); + + Assertions.assertTrue(authSecretName.matches(expectedPattern), + "Auth secret name should match the expected pattern"); + } + + @Test + void initV1McpBridgeTestShouldSetDefaultValues() { + V1McpBridge v1McpBridge = new V1McpBridge(); + + converter.initV1McpBridge(v1McpBridge); + + Assertions.assertNotNull(v1McpBridge.getMetadata(), "Metadata should not be null"); + Assertions.assertEquals(V1McpBridge.DEFAULT_NAME, v1McpBridge.getMetadata().getName(), + "Metadata name should be set to default"); + Assertions.assertNotNull(v1McpBridge.getSpec(), "Spec should not be null"); + Assertions.assertNotNull(v1McpBridge.getSpec().getRegistries(), "Spec registries should not be null"); + Assertions.assertEquals(0, v1McpBridge.getSpec().getRegistries().size(), + "Spec registries should be initialized as empty list"); + } + + @Test + public void addV1McpBridgeRegistryTestRegistryDoesNotExistShouldAddAndReturnRegistryConfig() { + V1McpBridge v1McpBridge = new V1McpBridge(); + V1McpBridgeSpec spec = new V1McpBridgeSpec(); + v1McpBridge.setSpec(spec); + + List registries = new ArrayList<>(); + spec.setRegistries(registries); + + ServiceSource serviceSource = + new ServiceSource("testService", "1.0", "http", "test.domain.com", 8080, new HashMap<>(), null); + + V1RegistryConfig result = converter.addV1McpBridgeRegistry(v1McpBridge, serviceSource); + + Assertions.assertNotNull(result); + Assertions.assertEquals(serviceSource.getName(), result.getName()); + Assertions.assertEquals(serviceSource.getDomain(), result.getDomain()); + Assertions.assertEquals(serviceSource.getType(), result.getType()); + Assertions.assertEquals(serviceSource.getPort(), result.getPort()); + Assertions.assertTrue(registries.contains(result)); + } + + @Test + public void addV1McpBridgeRegistryTestRegistryExistsShouldUpdateAndReturnRegistryConfig() { + V1McpBridge v1McpBridge = new V1McpBridge(); + V1McpBridgeSpec spec = new V1McpBridgeSpec(); + v1McpBridge.setSpec(spec); + + List registries = new ArrayList<>(); + V1RegistryConfig existingRegistry = new V1RegistryConfig(); + existingRegistry.setName("testService"); + registries.add(existingRegistry); + spec.setRegistries(registries); + + ServiceSource serviceSource = + new ServiceSource("testService", "1.0", "http", "test.domain.com", 8080, new HashMap<>(), null); + + V1RegistryConfig result = converter.addV1McpBridgeRegistry(v1McpBridge, serviceSource); + + Assertions.assertNotNull(result); + Assertions.assertEquals(serviceSource.getName(), result.getName()); + Assertions.assertEquals(serviceSource.getDomain(), result.getDomain()); + Assertions.assertEquals(serviceSource.getType(), result.getType()); + Assertions.assertEquals(serviceSource.getPort(), result.getPort()); + Assertions.assertTrue(registries.contains(result)); + } + + @Test + public void addV1McpBridgeRegistryTestNullServiceSourceShouldReturnNull() { + V1McpBridge v1McpBridge = new V1McpBridge(); + V1McpBridgeSpec spec = new V1McpBridgeSpec(); + v1McpBridge.setSpec(spec); + + List registries = new ArrayList<>(); + spec.setRegistries(registries); + + V1RegistryConfig result = converter.addV1McpBridgeRegistry(v1McpBridge, null); + + Assertions.assertNull(result); + } + + @Test + public void addV1McpBridgeRegistryTestNullSpecShouldCreateSpecAndAddRegistry() { + V1McpBridge v1McpBridge = new V1McpBridge(); + + ServiceSource serviceSource = + new ServiceSource("testService", "1.0", "http", "test.domain.com", 8080, new HashMap<>(), null); + + V1RegistryConfig result = converter.addV1McpBridgeRegistry(v1McpBridge, serviceSource); + + Assertions.assertNotNull(result); + Assertions.assertNotNull(v1McpBridge.getSpec()); + Assertions.assertNotNull(v1McpBridge.getSpec().getRegistries()); + Assertions.assertTrue(v1McpBridge.getSpec().getRegistries().contains(result)); + } + + @Test + public void addV1McpBridgeRegistryTestNullRegistriesShouldCreateRegistriesAndAddRegistry() { + V1McpBridge v1McpBridge = new V1McpBridge(); + V1McpBridgeSpec spec = new V1McpBridgeSpec(); + v1McpBridge.setSpec(spec); + + ServiceSource serviceSource = + new ServiceSource("testService", "1.0", "http", "test.domain.com", 8080, new HashMap<>(), null); + + V1RegistryConfig result = converter.addV1McpBridgeRegistry(v1McpBridge, serviceSource); + + Assertions.assertNotNull(result); + Assertions.assertNotNull(v1McpBridge.getSpec().getRegistries()); + Assertions.assertTrue(v1McpBridge.getSpec().getRegistries().contains(result)); + } + + @Test + public void removeV1McpBridgeRegistryTestRegistryExistsShouldRemoveAndReturnRegistryConfig() { + V1McpBridge v1McpBridge = new V1McpBridge(); + V1McpBridgeSpec spec = new V1McpBridgeSpec(); + v1McpBridge.setSpec(spec); + + List registries = new ArrayList<>(); + V1RegistryConfig registryConfig = new V1RegistryConfig(); + registryConfig.setName("testRegistry"); + registries.add(registryConfig); + spec.setRegistries(registries); + + V1RegistryConfig result = converter.removeV1McpBridgeRegistry(v1McpBridge, "testRegistry"); + + Assertions.assertNotNull(result); + Assertions.assertEquals("testRegistry", result.getName()); + Assertions.assertTrue(spec.getRegistries().isEmpty()); + } + + @Test + public void removeV1McpBridgeRegistryTestRegistryDoesNotExistShouldReturnNull() { + V1McpBridge v1McpBridge = new V1McpBridge(); + V1McpBridgeSpec spec = new V1McpBridgeSpec(); + v1McpBridge.setSpec(spec); + + List registries = new ArrayList<>(); + V1RegistryConfig registryConfig = new V1RegistryConfig(); + registryConfig.setName("existingRegistry"); + registries.add(registryConfig); + spec.setRegistries(registries); + + V1RegistryConfig result = converter.removeV1McpBridgeRegistry(v1McpBridge, "nonExistingRegistry"); + + Assertions.assertNull(result); + Assertions.assertEquals(1, spec.getRegistries().size()); + } + + @Test + public void removeV1McpBridgeRegistryTestNoRegistriesShouldReturnNull() { + V1McpBridge v1McpBridge = new V1McpBridge(); + V1McpBridgeSpec spec = new V1McpBridgeSpec(); + v1McpBridge.setSpec(spec); + + spec.setRegistries(new ArrayList<>()); + + V1RegistryConfig result = converter.removeV1McpBridgeRegistry(v1McpBridge, "testRegistry"); + + Assertions.assertNull(result); + } + + @Test + public void removeV1McpBridgeRegistryTestNullSpecShouldReturnNull() { + V1McpBridge v1McpBridge = new V1McpBridge(); + + v1McpBridge.setSpec(null); + + V1RegistryConfig result = converter.removeV1McpBridgeRegistry(v1McpBridge, "testRegistry"); + + Assertions.assertNull(result); + } + + @Test + void ingress2RouteTestValidIngressWithSingleRule() { + V1IngressBackend backend = new V1IngressBackend(); + V1IngressSpec spec = new V1IngressSpec(); + spec.setDefaultBackend(backend); + + V1ObjectMeta metadata = new V1ObjectMeta(); + metadata.setName("test-ingress"); + metadata.setResourceVersion("1"); + + V1Ingress ingress = new V1Ingress(); + ingress.setMetadata(metadata); + ingress.setSpec(spec); + + Route route = converter.ingress2Route(ingress); + + Assertions.assertNotNull(route); + Assertions.assertEquals("test-ingress", route.getName()); + Assertions.assertEquals("1", route.getVersion()); + } + + @Test + void ingress2RouteTestValidIngressWithMultipleRules() { + V1IngressBackend backend = new V1IngressBackend(); + V1IngressSpec spec = new V1IngressSpec(); + spec.setDefaultBackend(backend); + + V1ObjectMeta metadata = new V1ObjectMeta(); + metadata.setName("test-ingress"); + metadata.setResourceVersion("1"); + + V1IngressRule rule1 = new V1IngressRule(); + rule1.setHost("example.com"); + + V1IngressRule rule2 = new V1IngressRule(); + rule2.setHost("test.example.com"); + + spec.setRules(Arrays.asList(rule1, rule2)); + + V1Ingress ingress = new V1Ingress(); + ingress.setMetadata(metadata); + ingress.setSpec(spec); + + Route route = converter.ingress2Route(ingress); + + Assertions.assertNotNull(route); + Assertions.assertEquals("test-ingress", route.getName()); + Assertions.assertEquals("1", route.getVersion()); + Assertions.assertEquals(null, route.getDomains()); + } + + @Test + void ingress2RouteTestValidIngressWithTLS() { + V1IngressBackend backend = new V1IngressBackend(); + V1IngressSpec spec = new V1IngressSpec(); + spec.setDefaultBackend(backend); + + V1ObjectMeta metadata = new V1ObjectMeta(); + metadata.setName("test-ingress"); + metadata.setResourceVersion("1"); + + V1IngressTLS tls = new V1IngressTLS(); + tls.setHosts(Arrays.asList("higress.cn", "test.higress.cn")); + + spec.setTls(Arrays.asList(tls)); + + V1Ingress ingress = new V1Ingress(); + ingress.setMetadata(metadata); + ingress.setSpec(spec); + + Route route = converter.ingress2Route(ingress); + + Assertions.assertNotNull(route); + Assertions.assertEquals("test-ingress", route.getName()); + Assertions.assertEquals("1", route.getVersion()); + Assertions.assertEquals(null, route.getDomains()); + } + + @Test + void ingress2RouteTestValidIngressWithAnnotationsReturnsValidRoute() { + V1IngressBackend backend = new V1IngressBackend(); + V1IngressSpec spec = new V1IngressSpec(); + spec.setDefaultBackend(backend); + + V1ObjectMeta metadata = new V1ObjectMeta(); + metadata.setName("test-ingress"); + metadata.setResourceVersion("1"); + metadata.setAnnotations(new HashMap() {{ + put("higress.cn", "annotation-value"); + }}); + + V1Ingress ingress = new V1Ingress(); + ingress.setMetadata(metadata); + ingress.setSpec(spec); + + Route route = converter.ingress2Route(ingress); + + Assertions.assertNotNull(route); + Assertions.assertEquals("test-ingress", route.getName()); + Assertions.assertEquals("1", route.getVersion()); + Assertions.assertNotNull(route.getCustomConfigs()); + Assertions.assertEquals("annotation-value", route.getCustomConfigs().get("higress.cn")); + } + + @Test + void ingress2RouteTestNullMetadataReturnsRouteWithDefaults() { + V1IngressBackend backend = new V1IngressBackend(); + V1IngressSpec spec = new V1IngressSpec(); + spec.setDefaultBackend(backend); + + V1Ingress ingress = new V1Ingress(); + ingress.setSpec(spec); + + Route route = converter.ingress2Route(ingress); + + Assertions.assertNotNull(route); + Assertions.assertEquals(null, route.getName()); + Assertions.assertEquals(null, route.getVersion()); + } + + @Test + void ingress2RouteTestNullSpecReturnsRouteWithDefaults() { + V1ObjectMeta metadata = new V1ObjectMeta(); + metadata.setName("test-ingress"); + metadata.setResourceVersion("1"); + + V1Ingress ingress = new V1Ingress(); + ingress.setMetadata(metadata); + + Route route = converter.ingress2Route(ingress); + + Assertions.assertNotNull(route); + Assertions.assertEquals("test-ingress", route.getName()); + Assertions.assertEquals("1", route.getVersion()); + Assertions.assertNull(route.getDomains()); + } + + @Test + public void domainName2ConfigMapNameTestNormalDomainNameConfigMapNameCreated() { + String domainName = "www.higress.cn"; + String expectedConfigMapName = "domain-www.higress.cn"; + String actualConfigMapName = converter.domainName2ConfigMapName(domainName); + + Assertions.assertEquals(expectedConfigMapName, actualConfigMapName, + "ConfigMap name should be 'domain-www.higress.cn'"); + } + + @Test + public void domainName2ConfigMapNameTestWildcardDomainNameConfigMapNameCreated() { + String domainName = "*.higress.cn"; + String expectedConfigMapName = "domain-wildcard.higress.cn"; + String actualConfigMapName = converter.domainName2ConfigMapName(domainName); + + Assertions.assertEquals(expectedConfigMapName, actualConfigMapName, + "ConfigMap name should be 'domain-wildcard.higress.cn'"); + } + private V1Ingress buildBasicSupportedIngress() { V1Ingress ingress = new V1Ingress(); @@ -584,4 +1803,67 @@ private static Route buildBasicRoute() { route.setCustomConfigs(new HashMap<>()); return route; } + + private V1alpha1WasmPluginSpec createSpecWithGlobalConfig() { + V1alpha1WasmPluginSpec spec = new V1alpha1WasmPluginSpec(); + spec.setDefaultConfig(Collections.singletonMap("key", "value")); + spec.setDefaultConfigDisable(false); + return spec; + } + + private V1alpha1WasmPluginSpec createSpecWithDomainConfig(String domain) { + V1alpha1WasmPluginSpec spec = new V1alpha1WasmPluginSpec(); + MatchRule matchRule = new MatchRule(); + matchRule.setDomain(Collections.singletonList(domain)); + matchRule.setConfig(Collections.singletonMap("key", "value")); + matchRule.setConfigDisable(false); + spec.setMatchRules(Collections.singletonList(matchRule)); + return spec; + } + + private V1alpha1WasmPluginSpec createSpecWithRouteConfig(String route) { + V1alpha1WasmPluginSpec spec = new V1alpha1WasmPluginSpec(); + MatchRule matchRule = new MatchRule(); + matchRule.setIngress(Collections.singletonList(route)); + matchRule.setConfig(Collections.singletonMap("key", "value")); + matchRule.setConfigDisable(false); + spec.setMatchRules(Collections.singletonList(matchRule)); + return spec; + } + + private V1ObjectMeta createMetadata(String name, String pluginName, String pluginVersion) { + Map labels = new HashMap<>(); + labels.put(KubernetesConstants.Label.WASM_PLUGIN_NAME_KEY, pluginName); + labels.put(KubernetesConstants.Label.WASM_PLUGIN_VERSION_KEY, pluginVersion); + V1ObjectMeta metadata = new V1ObjectMeta(); + metadata.setName(name); + metadata.setLabels(labels); + return metadata; + } + + private V1ObjectMeta createMetadata() { + Map labels = new HashMap<>(); + labels.put(KubernetesConstants.Label.WASM_PLUGIN_NAME_KEY, "test-plugin"); + labels.put(KubernetesConstants.Label.WASM_PLUGIN_VERSION_KEY, "v1"); + labels.put(KubernetesConstants.Label.WASM_PLUGIN_CATEGORY_KEY, "test-category"); + labels.put(KubernetesConstants.Label.WASM_PLUGIN_BUILT_IN_KEY, "true"); + + Map annotations = new HashMap<>(); + annotations.put(KubernetesConstants.Annotation.WASM_PLUGIN_TITLE_KEY, "Test Plugin"); + annotations.put(KubernetesConstants.Annotation.WASM_PLUGIN_DESCRIPTION_KEY, "A test plugin"); + annotations.put(KubernetesConstants.Annotation.WASM_PLUGIN_ICON_KEY, "icon.png"); + + V1ObjectMeta metadata = new V1ObjectMeta(); + metadata.setLabels(labels); + metadata.setAnnotations(annotations); + return metadata; + } + + private V1alpha1WasmPluginSpec createSpec() { + V1alpha1WasmPluginSpec spec = new V1alpha1WasmPluginSpec(); + spec.setUrl("test/image:v1"); + spec.setPhase(PluginPhase.UNSPECIFIED.getName()); + spec.setPriority(10); + return spec; + } } diff --git a/frontend/src/locales/en-US/translation.json b/frontend/src/locales/en-US/translation.json index 0e9ac1c1..e7b90696 100644 --- a/frontend/src/locales/en-US/translation.json +++ b/frontend/src/locales/en-US/translation.json @@ -145,7 +145,11 @@ "set": "Set", "remove": "Remove", "action": "Action", - "addNewRule": "Add New Rule" + "addNewRule": "Add New Rule", + "headerTypeRequired": "Please select header type", + "actionTypeRequired": "Please select action type", + "keyRequired": "Please input header key", + "valueRequired": "Please input header value" }, "cors": { "allowOrigins": "Allow Origins", diff --git a/frontend/src/locales/zh-CN/translation.json b/frontend/src/locales/zh-CN/translation.json index 6dd0a786..afff4d2f 100644 --- a/frontend/src/locales/zh-CN/translation.json +++ b/frontend/src/locales/zh-CN/translation.json @@ -145,7 +145,11 @@ "set": "更新", "remove": "删除", "action": "操作", - "addNewRule": "添加新规则" + "addNewRule": "添加新规则", + "headerTypeRequired": "请选择Header类型", + "actionTypeRequired": "请选择操作类型", + "keyRequired": "请输入Header Key", + "valueRequired": "请输入Header Value" }, "cors": { "allowOrigins": "允许的访问来源", diff --git a/frontend/src/pages/plugin/components/HeaderModify/index.tsx b/frontend/src/pages/plugin/components/HeaderModify/index.tsx index f020f84f..6e1ef3b6 100644 --- a/frontend/src/pages/plugin/components/HeaderModify/index.tsx +++ b/frontend/src/pages/plugin/components/HeaderModify/index.tsx @@ -48,7 +48,10 @@ const HeaderModify = forwardRef((props, ref) => { return ( @@ -69,7 +72,10 @@ const HeaderModify = forwardRef((props, ref) => { return ( @@ -90,7 +96,10 @@ const HeaderModify = forwardRef((props, ref) => { return ( @@ -104,11 +113,23 @@ const HeaderModify = forwardRef((props, ref) => { title: 'Header Value', key: 'value', dataIndex: 'value', - render(text, field, record) { + render(text, field, index) { return (