Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: add chatgpt-azure-director #435

Closed
wants to merge 3 commits into from
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
31 changes: 31 additions & 0 deletions plugins/wasm-go/extensions/chatgpt-azure-director/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
# 功能说明

`chatgpt-azure-director` 将 OpenAI v1 协议的接口转换为 Azure OpenAI 协议。
为 OpenAI 开发的 ChatGPT 客户端应用不必修改客户端代码即可将 ChatGPT 服务商或实例从 OpenAI 切换到 Azure.

此外, 还支持了虚拟 api-key, 开发者可以自行构造虚拟的 api-key, 分发给其他用户, 可以基于这个虚拟 api-key 对用户进行审计、计费等。
请求经过 `chatgpt-azure-director` 时,插件将虚拟 api-key 替换成真实的 Azure api-key。

## 目前支持的转换的接口

| | |
|-------------------------|-----------------------------------------------------------|
| `/v1/models` | "/openai/models" |
| `^/v1/models/{model}` | `/openai/models/{model}` |
| `^/v1/completions` | `/openai/developments/{DevelopmentName}/completions` |
| `^/v1/chat/completions` | `/openai/developments/{DevelopmentName}/chat/completions` |

# 配置字段

| 名称 | 数据类型 | 填写要求 | 默认值 | 描述 |
|-----------------|-------------------|-------------|-----------------------------------|---------------------------|
| allowedKeys | []string 字符串类型的数组 | 必填, 长度至少为 1 | - | 虚拟 api-key 列表, 管理员构造和二次分发 |
| apiKey | string | 必填 | - | Azure OpenAI 服务 api-key |
| apiVersion | string | 选填 | 2023-03-15-preview | Azure OpenAI API 版本 |
| developmentName | string | 必填 | - | Azure 上部署的实例名称 |
| scheme | string | 选填 | https | Azure 上部署的实例的 http scheme |
| azureHost | string | 选填 | {deploymentName}.openai.azure.com | Azure 上部署的实例的域名 |

# 配置示例

见本目下 [envoy.yaml](envoy.yaml) 文件。
103 changes: 103 additions & 0 deletions plugins/wasm-go/extensions/chatgpt-azure-director/envoy.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
static_resources:
listeners:
- name: main
address:
socket_address:
address: 0.0.0.0
port_value: 18000
filter_chains:
- filters:
- name: envoy.http_connection_manager
typed_config:
"@type": type.googleapis.com/envoy.extensions.filters.network.http_connection_manager.v3.HttpConnectionManager
stat_prefix: ingress_http
codec_type: auto
route_config:
name: local_route
virtual_hosts:
- name: local_service
domains: ["*"]
routes:
- match:
prefix: "/"
route:
cluster: dynamic_forward_proxy_cluster
http_filters:
- name: envoy.filters.http.wasm
typed_config:
"@type": type.googleapis.com/udpa.type.v1.TypedStruct
type_url: type.googleapis.com/envoy.extensions.filters.http.wasm.v3.Wasm
value:
config:
configuration:
"@type": type.googleapis.com/google.protobuf.StringValue
value: |-
{
"allowedKeys": ["<any-key-you-made>"],
"apiKey": "<the-real-api-key-you-got-from-azure-openai-instance>",
"apiVersion": "2023-03-15-preview",
"developmentName": "<your-azure-deployment-name>",
"scheme": "https",
"azureHost": "<your-azure-deployment-name.openai.azure.com>"
}
vm_config:
runtime: "envoy.wasm.runtime.v8"
code:
local:
filename: "/data/plugin.wasm"
- name: envoy.filters.http.dynamic_forward_proxy
typed_config:
"@type": type.googleapis.com/envoy.extensions.filters.http.dynamic_forward_proxy.v3.FilterConfig
dns_cache_config:
name: dynamic_forward_proxy_cache_config
dns_lookup_family: V4_ONLY
typed_dns_resolver_config:
name: envoy.network.dns_resolver.cares
typed_config:
"@type": type.googleapis.com/envoy.extensions.network.dns_resolver.cares.v3.CaresDnsResolverConfig
resolvers:
- socket_address:
address: "8.8.8.8"
port_value: 53
dns_resolver_options:
use_tcp_for_dns_lookups: true
no_default_search_domain: true
- name: envoy.filters.http.router
typed_config:
"@type": type.googleapis.com/envoy.extensions.filters.http.router.v3.Router

clusters:
- name: dynamic_forward_proxy_cluster
lb_policy: CLUSTER_PROVIDED
cluster_type:
name: envoy.clusters.dynamic_forward_proxy
typed_config:
"@type": type.googleapis.com/envoy.extensions.clusters.dynamic_forward_proxy.v3.ClusterConfig
dns_cache_config:
name: dynamic_forward_proxy_cache_config
dns_lookup_family: V4_ONLY
typed_dns_resolver_config:
name: envoy.network.dns_resolver.cares
typed_config:
"@type": type.googleapis.com/envoy.extensions.network.dns_resolver.cares.v3.CaresDnsResolverConfig
resolvers:
- socket_address:
address: "8.8.8.8"
port_value: 53
dns_resolver_options:
use_tcp_for_dns_lookups: true
no_default_search_domain: true
transport_socket:
name: envoy.transport_sockets.tls
typed_config:
"@type": type.googleapis.com/envoy.extensions.transport_sockets.tls.v3.UpstreamTlsContext
common_tls_context:
validation_context:
trusted_ca: {filename: /etc/ssl/certs/ca-certificates.crt}

admin:
access_log_path: "/dev/null"
address:
socket_address:
address: 0.0.0.0
port_value: 8001
15 changes: 15 additions & 0 deletions plugins/wasm-go/extensions/chatgpt-azure-director/go.mod
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
module chatgpt-proxy

go 1.19

require (
github.com/alibaba/higress/plugins/wasm-go v0.0.0-20230629030002-81e467b6242d
github.com/tetratelabs/proxy-wasm-go-sdk v0.22.0
github.com/tidwall/gjson v1.14.4
)

require (
github.com/google/uuid v1.3.0 // indirect
github.com/tidwall/match v1.1.1 // indirect
github.com/tidwall/pretty v1.2.0 // indirect
)
16 changes: 16 additions & 0 deletions plugins/wasm-go/extensions/chatgpt-azure-director/go.sum
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
github.com/alibaba/higress/plugins/wasm-go v0.0.0-20230629030002-81e467b6242d h1:PyBnIfssGRMKvBf6wKH11Z1t+X1Z7qegD1pAO60sFrk=
github.com/alibaba/higress/plugins/wasm-go v0.0.0-20230629030002-81e467b6242d/go.mod h1:AzSnkuon5c26nIePTiJQIAFsKdhkNdncLcTuahpGtQs=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/google/uuid v1.3.0 h1:t6JiXgmwXMjEs8VusXIJk2BXHsn+wx8BZdTaoZ5fu7I=
github.com/google/uuid v1.3.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/stretchr/testify v1.8.0 h1:pSgiaMZlXftHpm5L7V1+rVB+AZJydKsMxsQBIJw4PKk=
github.com/tetratelabs/proxy-wasm-go-sdk v0.22.0 h1:kS7BvMKN+FiptV4pfwiNX8e3q14evxAWkhYbxt8EI1M=
github.com/tetratelabs/proxy-wasm-go-sdk v0.22.0/go.mod h1:qkW5MBz2jch2u8bS59wws65WC+Gtx3x0aPUX5JL7CXI=
github.com/tidwall/gjson v1.14.4 h1:uo0p8EbA09J7RQaflQ1aBRffTR7xedD2bcIVSYxLnkM=
github.com/tidwall/gjson v1.14.4/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk=
github.com/tidwall/match v1.1.1 h1:+Ho715JplO36QYgwN9PGYNhgZvoUSc9X2c80KVTi+GA=
github.com/tidwall/match v1.1.1/go.mod h1:eRSPERbgtNPcGhD8UCthc6PmLEQXEWd3PRB5JTxsfmM=
github.com/tidwall/pretty v1.2.0 h1:RWIZEg2iJ8/g6fDDYzMpobmaoGh5OLl4AXtGUGPcqCs=
github.com/tidwall/pretty v1.2.0/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
191 changes: 191 additions & 0 deletions plugins/wasm-go/extensions/chatgpt-azure-director/main.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,191 @@
// Copyright (c) 2022 Alibaba Group Holding Ltd.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.

package main

import (
"errors"
"net/url"
"regexp"
"strings"

"github.com/alibaba/higress/plugins/wasm-go/pkg/wrapper"
"github.com/tetratelabs/proxy-wasm-go-sdk/proxywasm"
"github.com/tetratelabs/proxy-wasm-go-sdk/proxywasm/types"
"github.com/tidwall/gjson"
)

var pathMatchers = []*PathMatcher{
NewPathMatcher(`^/v1/models$`, "/openai/models"),
NewPathMatcher(`^/v1/models/(?P<model>[^/]+)$`, "/openai/models/${ path.model }"),
NewPathMatcher(`^/v1/completions$`, "/openai/developments/${ config.DevelopmentName }/completions"),
NewPathMatcher(`^/v1/chat/completions$`, "/openai/developments/${ config.DevelopmentName }/chat/completions"),
}

func main() {
wrapper.SetCtx(
"chatgpt-azure-director",
wrapper.ParseConfigBy(parseConfig),
wrapper.ProcessRequestHeadersBy(onHttpRequestHeaders),
)
}

type Config struct {
AllowedKeys map[string]struct{}
APIKey string
APIVersion string
DevelopmentName string
Scheme string
AzureHost string
}

func parseConfig(json gjson.Result, config *Config, log wrapper.Log) error {
if !json.Get("allowedKeys").IsArray() {
return errors.New("allowedKeys type error, it should be an array")
}
array := json.Get("allowedKeys").Array()
if len(array) == 0 {
return errors.New("at least one key in allowedKeys")
}
if config.AllowedKeys == nil {
config.AllowedKeys = make(map[string]struct{})
}
for _, item := range array {
config.AllowedKeys[item.String()] = struct{}{}
}

config.APIKey = json.Get("apiKey").String()
if config.APIKey == "" {
return errors.New("invalid apiKey")
}

config.APIVersion = json.Get("apiVersion").String()
if config.APIVersion == "" {
config.APIVersion = "2023-03-15-preview"
}

config.DevelopmentName = json.Get("developmentName").String()
if config.DevelopmentName == "" {
return errors.New("invalid developmentName")
}

config.Scheme = json.Get("scheme").String()
if config.Scheme != "http" {
config.Scheme = "https"
}

config.AzureHost = json.Get("azureHost").String()
if config.AzureHost == "" {
config.AzureHost = config.DevelopmentName + ".openai.azure.com"
}

return nil
}

func onHttpRequestHeaders(ctx wrapper.HttpContext, config Config, log wrapper.Log) types.Action {
u, err := url.Parse(ctx.Path())
if err != nil {
_ = proxywasm.SendHttpResponse(400, nil, []byte("invalid path"), -1)
return types.ActionContinue
}
for _, matcher := range pathMatchers {
if matcher.Match(u.Path) {
action := rewrite(ctx, config, matcher, u)
scheme, _ := proxywasm.GetHttpRequestHeader(":scheme")
host, _ := proxywasm.GetHttpRequestHeader(":host")
path, _ := proxywasm.GetHttpRequestHeader(":path")
log.Debugf("the directed request: %s://%s%s\n", scheme, host, path)
return action
}
}
return types.ActionContinue
}

func rewrite(ctx wrapper.HttpContext, config Config, matcher *PathMatcher, u *url.URL) types.Action {
// check authorization
authorization, err := proxywasm.GetHttpRequestHeader("Authorization")
if err != nil || authorization == "" {
_ = proxywasm.SendHttpResponse(401, nil, []byte("Authorization need"), -1)
return types.ActionContinue
}
if _, ok := config.AllowedKeys[strings.TrimPrefix(authorization, "Bearer ")]; !ok {
_ = proxywasm.SendHttpResponse(403, nil, []byte("Authorization is invalid"), -1)
return types.ActionContinue
}

// reset authorization
_ = proxywasm.RemoveHttpRequestHeader("Authorization")
_ = proxywasm.RemoveHttpRequestHeader("Api-Key")
_ = proxywasm.AddHttpResponseHeader("Api-Key", config.APIKey)

// set api-version in queries
query := u.Query()
if query.Get("api-version") == "" {
query.Set("api-version", config.APIVersion)
}
u.RawQuery = query.Encode()

// set rewritten path
var rewrite = matcher.AzurePathPattern
rewrite = strings.ReplaceAll(rewrite, "${ path.model }", matcher.Values["model"])
rewrite = strings.ReplaceAll(rewrite, "${ config.DevelopmentName }", config.DevelopmentName)
u.Path = rewrite

// reset uri
_ = proxywasm.ReplaceHttpRequestHeader(":path", u.RequestURI())

// rewrite host
_ = proxywasm.ReplaceHttpRequestHeader(":host", config.AzureHost)

// rewrite scheme
_ = proxywasm.ReplaceHttpRequestHeader(":scheme", config.Scheme)

return types.ActionContinue
}

func NewPathMatcher(openaiPathExpr, azurePathPattern string) *PathMatcher {
re := regexp.MustCompile(openaiPathExpr)
var pm = PathMatcher{
OpenaiPathExpr: openaiPathExpr,
AzurePathPattern: azurePathPattern,
Values: make(map[string]string),
}
pm.match = func(path string) bool {
if !re.MatchString(path) {
return false
}
matches := re.FindAllStringSubmatch(path, -1)
for _, subs := range matches {
for i, name := range re.SubexpNames() {
if i > 0 && i < len(subs) {
pm.Values[name] = subs[i]
}
}
}
return true
}
return &pm
}

type PathMatcher struct {
OpenaiPathExpr string
AzurePathPattern string
Values map[string]string

match func(path string) bool
}

func (p *PathMatcher) Match(path string) bool {
return p.match(path)
}
Loading