Skip to content
Open
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
56 changes: 56 additions & 0 deletions plugins/wasm-go/extensions/ai-proxy/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -192,6 +192,10 @@ OpenRouter 所对应的 `type` 为 `openrouter`。它并无特有的配置字段

Fireworks AI 所对应的 `type` 为 `fireworks`。它并无特有的配置字段。

#### 七牛云(Qiniu)

七牛云所对应的 `type` 为 `qiniu`。它并无特有的配置字段。

#### 文心一言(Baidu)

文心一言所对应的 `type` 为 `baidu`。它并无特有的配置字段。
Expand Down Expand Up @@ -2439,6 +2443,58 @@ providers:
}
```


### 使用 OpenAI 协议代理七牛云服务

**配置信息**

```yaml
provider:
type: qiniu
apiTokens:
- "YOUR_QINIU_API_TOKEN"
```

**请求示例**

```json
{
"model": "openai/gpt-5",
"messages": [
{
"role": "user",
"content": "你好,你是谁?"
}
]
}
```

**响应示例**

```json
{
"id": "chatcmpl-xxxx",
"object": "chat.completion",
"created": 1699123456,
"model": "openai/gpt-5",
"choices": [
{
"index": 0,
"message": {
"role": "assistant",
"content": "你好!我是 Codex,基于 GPT-5 的编码助手,在你的本机 Codex CLI 里工作。有什么可以帮助你的吗?"
},
"finish_reason": "stop"
}
],
"usage": {
"prompt_tokens": 10,
"completion_tokens": 25,
"total_tokens": 35
}
}
```

### 使用上下文清理命令

配置上下文清理命令后,用户可以通过发送特定消息来主动清理对话历史,实现"重新开始对话"的效果。
Expand Down
6 changes: 6 additions & 0 deletions plugins/wasm-go/extensions/ai-proxy/main_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -212,3 +212,9 @@ func TestConsumerAffinity(t *testing.T) {
test.RunConsumerAffinityParseConfigTests(t)
test.RunConsumerAffinityOnHttpRequestHeadersTests(t)
}

func TestQiniu(t *testing.T) {
test.RunQiniuParseConfigTests(t)
test.RunQiniuOnHttpRequestHeadersTests(t)
test.RunQiniuOnHttpRequestBodyTests(t)
}
2 changes: 2 additions & 0 deletions plugins/wasm-go/extensions/ai-proxy/provider/provider.go
Original file line number Diff line number Diff line change
Expand Up @@ -147,6 +147,7 @@ const (
providerTypeLongcat = "longcat"
providerTypeFireworks = "fireworks"
providerTypeVllm = "vllm"
providerTypeQiniu = "qiniu"
providerTypeGeneric = "generic"

protocolOpenAI = "openai"
Expand Down Expand Up @@ -238,6 +239,7 @@ var (
providerTypeLongcat: &longcatProviderInitializer{},
providerTypeFireworks: &fireworksProviderInitializer{},
providerTypeVllm: &vllmProviderInitializer{},
providerTypeQiniu: &qiniuProviderInitializer{},
providerTypeGeneric: &genericProviderInitializer{},
}
)
Expand Down
68 changes: 68 additions & 0 deletions plugins/wasm-go/extensions/ai-proxy/provider/qiniu.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
package provider

import (
"errors"
"net/http"

"github.com/alibaba/higress/plugins/wasm-go/extensions/ai-proxy/util"
"github.com/higress-group/proxy-wasm-go-sdk/proxywasm/types"
"github.com/higress-group/wasm-go/pkg/wrapper"
)

// qiniuProvider is the provider for Qiniu AI service.
const (
qiniuDomain = "api.qnaigc.com"
)

type qiniuProviderInitializer struct{}

func (m *qiniuProviderInitializer) ValidateConfig(config *ProviderConfig) error {
if len(config.apiTokens) == 0 {
return errors.New("no apiToken found in provider config")
}
return nil
}

func (m *qiniuProviderInitializer) DefaultCapabilities() map[string]string {
return map[string]string{
string(ApiNameChatCompletion): PathOpenAIChatCompletions,
string(ApiNameModels): PathOpenAIModels,
}
}

func (m *qiniuProviderInitializer) CreateProvider(config ProviderConfig) (Provider, error) {
config.setDefaultCapabilities(m.DefaultCapabilities())
return &qiniuProvider{
config: config,
contextCache: createContextCache(&config),
}, nil
}

type qiniuProvider struct {
config ProviderConfig
contextCache *contextCache
}

func (m *qiniuProvider) GetProviderType() string {
return providerTypeQiniu
}

func (m *qiniuProvider) OnRequestHeaders(ctx wrapper.HttpContext, apiName ApiName) error {
m.config.handleRequestHeaders(m, ctx, apiName)
return nil
}

func (m *qiniuProvider) OnRequestBody(ctx wrapper.HttpContext, apiName ApiName, body []byte) (types.Action, error) {
if !m.config.isSupportedAPI(apiName) {
return types.ActionContinue, errUnsupportedApiName
}
return m.config.handleRequestBody(m, m.contextCache, ctx, apiName, body)
}

func (m *qiniuProvider) TransformRequestHeaders(ctx wrapper.HttpContext, apiName ApiName, headers http.Header) {
util.OverwriteRequestPathHeaderByCapability(headers, string(apiName), m.config.capabilities)
util.OverwriteRequestHostHeader(headers, qiniuDomain)
util.OverwriteRequestAuthorizationHeader(headers, "Bearer "+m.config.GetApiTokenInUse(ctx))
headers.Set("X-Forwarded-Proto", "https") // 由于qiniu 网关设置,强制校验X-Forwarded-Proto为https
headers.Del("Content-Length")
}
117 changes: 117 additions & 0 deletions plugins/wasm-go/extensions/ai-proxy/test/qiniu.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,117 @@
package test

import (
"encoding/json"
"testing"

"github.com/higress-group/proxy-wasm-go-sdk/proxywasm/types"
"github.com/higress-group/wasm-go/pkg/test"
"github.com/stretchr/testify/require"
)

var basicQiniuConfig = func() json.RawMessage {
data, _ := json.Marshal(map[string]interface{}{
"provider": map[string]interface{}{
"type": "qiniu",
"apiTokens": []string{"qiniu-test-token-123"},
"modelMapping": map[string]string{
"*": "Qwen/Qwen2.5-7B-Instruct",
},
},
})
return data
}()

var invalidQiniuConfig = func() json.RawMessage {
data, _ := json.Marshal(map[string]interface{}{
"provider": map[string]interface{}{
"type": "qiniu",
"apiTokens": []string{},
},
})
return data
}()

func RunQiniuParseConfigTests(t *testing.T) {
test.RunGoTest(t, func(t *testing.T) {
t.Run("basic qiniu config", func(t *testing.T) {
host, status := test.NewTestHost(basicQiniuConfig)
defer host.Reset()
require.Equal(t, types.OnPluginStartStatusOK, status)

config, err := host.GetMatchConfig()
require.NoError(t, err)
require.NotNil(t, config)
})

t.Run("invalid qiniu config - missing apiToken", func(t *testing.T) {
host, status := test.NewTestHost(invalidQiniuConfig)
defer host.Reset()
require.Equal(t, types.OnPluginStartStatusFailed, status)
})
})
}

func RunQiniuOnHttpRequestHeadersTests(t *testing.T) {
test.RunTest(t, func(t *testing.T) {
t.Run("qiniu chat completion request headers", func(t *testing.T) {
host, status := test.NewTestHost(basicQiniuConfig)
defer host.Reset()
require.Equal(t, types.OnPluginStartStatusOK, status)

action := host.CallOnHttpRequestHeaders([][2]string{
{":authority", "example.com"},
{":path", "/v1/chat/completions"},
{":method", "POST"},
{"Content-Type", "application/json"},
})
require.Equal(t, types.HeaderStopIteration, action)

requestHeaders := host.GetRequestHeaders()

// 验证 Host 替换为七牛域名
hostValue, hasHost := test.GetHeaderValue(requestHeaders, ":authority")
require.True(t, hasHost)
require.Equal(t, "api.qnaigc.com", hostValue)

// 验证 Authorization
authValue, hasAuth := test.GetHeaderValue(requestHeaders, "Authorization")
require.True(t, hasAuth)
require.Equal(t, "Bearer qiniu-test-token-123", authValue)

// 验证 Path
pathValue, hasPath := test.GetHeaderValue(requestHeaders, ":path")
require.True(t, hasPath)
require.Equal(t, "/v1/chat/completions", pathValue)

// 验证 Content-Length 被删除
_, hasContentLength := test.GetHeaderValue(requestHeaders, "Content-Length")
require.False(t, hasContentLength)
})
})
}

func RunQiniuOnHttpRequestBodyTests(t *testing.T) {
test.RunTest(t, func(t *testing.T) {
t.Run("qiniu chat completion request body", func(t *testing.T) {
host, status := test.NewTestHost(basicQiniuConfig)
defer host.Reset()
require.Equal(t, types.OnPluginStartStatusOK, status)

host.CallOnHttpRequestHeaders([][2]string{
{":authority", "example.com"},
{":path", "/v1/chat/completions"},
{":method", "POST"},
{"Content-Type", "application/json"},
})

requestBody := `{"model":"gpt-4","messages":[{"role":"user","content":"你好"}]}`
action := host.CallOnHttpRequestBody([]byte(requestBody))
require.Equal(t, types.ActionContinue, action)

processedBody := host.GetRequestBody()
// 验证模型被映射
require.Contains(t, string(processedBody), "Qwen/Qwen2.5-7B-Instruct")
})
})
}
Loading