diff --git a/plugins/wasm-go/extensions/ai-proxy/main.go b/plugins/wasm-go/extensions/ai-proxy/main.go index bb987ee54..4e2b14e61 100644 --- a/plugins/wasm-go/extensions/ai-proxy/main.go +++ b/plugins/wasm-go/extensions/ai-proxy/main.go @@ -69,6 +69,9 @@ var ( {provider.PathAnthropicComplete, provider.ApiNameAnthropicComplete}, // Cohere style {provider.PathCohereV1Rerank, provider.ApiNameCohereV1Rerank}, + // Qwen style + {provider.PathQwenV1Reranks, provider.ApiNameQwenV1Rerank}, + {provider.PathQwenV1Conversations, provider.ApiNameQwenV1Conversations}, } pathPatternToApiName = []pair[*regexp.Regexp, provider.ApiName]{ // OpenAI style diff --git a/plugins/wasm-go/extensions/ai-proxy/main_test.go b/plugins/wasm-go/extensions/ai-proxy/main_test.go index b0d34c2da..f06e4a339 100644 --- a/plugins/wasm-go/extensions/ai-proxy/main_test.go +++ b/plugins/wasm-go/extensions/ai-proxy/main_test.go @@ -56,6 +56,9 @@ func Test_getApiName(t *testing.T) { {"gemini stream generate content", "/v1beta/models/gemini-1.0-pro:streamGenerateContent", provider.ApiNameGeminiStreamGenerateContent}, // Cohere {"cohere rerank", "/v1/rerank", provider.ApiNameCohereV1Rerank}, + // Qwen + {"qwen reranks", "/v1/reranks", provider.ApiNameQwenV1Rerank}, + {"qwen conversations", "/v1/conversations", provider.ApiNameQwenV1Conversations}, // Unknown {"unknown", "/v1/unknown", ""}, } diff --git a/plugins/wasm-go/extensions/ai-proxy/provider/provider.go b/plugins/wasm-go/extensions/ai-proxy/provider/provider.go index e82007ca0..ba3645b76 100644 --- a/plugins/wasm-go/extensions/ai-proxy/provider/provider.go +++ b/plugins/wasm-go/extensions/ai-proxy/provider/provider.go @@ -71,6 +71,7 @@ const ( ApiNameQwenAsyncAIGC ApiName = "qwen/v1/services/aigc" ApiNameQwenAsyncTask ApiName = "qwen/v1/tasks" ApiNameQwenV1Rerank ApiName = "qwen/v1/rerank" + ApiNameQwenV1Conversations ApiName = "qwen/v1/conversations" ApiNameGeminiGenerateContent ApiName = "gemini/v1beta/generatecontent" ApiNameGeminiStreamGenerateContent ApiName = "gemini/v1beta/streamgeneratecontent" ApiNameAnthropicMessages ApiName = "anthropic/v1/messages" @@ -118,6 +119,10 @@ const ( // Cohere PathCohereV1Rerank = "/v1/rerank" + // Qwen + PathQwenV1Reranks = "/v1/reranks" + PathQwenV1Conversations = "/v1/conversations" + providerTypeMoonshot = "moonshot" providerTypeAzure = "azure" providerTypeAi360 = "ai360" diff --git a/plugins/wasm-go/extensions/ai-proxy/provider/qwen.go b/plugins/wasm-go/extensions/ai-proxy/provider/qwen.go index a4a6f8752..b49b3799b 100644 --- a/plugins/wasm-go/extensions/ai-proxy/provider/qwen.go +++ b/plugins/wasm-go/extensions/ai-proxy/provider/qwen.go @@ -27,9 +27,11 @@ const ( qwenChatCompletionPath = "/api/v1/services/aigc/text-generation/generation" qwenTextEmbeddingPath = "/api/v1/services/embeddings/text-embedding/text-embedding" qwenTextRerankPath = "/api/v1/services/rerank/text-rerank/text-rerank" + qwenCompatibleTextRerankPath = "/compatible-api/v1/reranks" qwenCompatibleChatCompletionPath = "/compatible-mode/v1/chat/completions" qwenCompatibleCompletionsPath = "/compatible-mode/v1/completions" qwenCompatibleTextEmbeddingPath = "/compatible-mode/v1/embeddings" + qwenCompatibleConversationsPath = "/compatible-mode/v1/conversations" qwenCompatibleResponsesPath = "/compatible-mode/v1/responses" qwenCompatibleFilesPath = "/compatible-mode/v1/files" qwenCompatibleRetrieveFilePath = "/compatible-mode/v1/files/{file_id}" @@ -78,7 +80,8 @@ func (m *qwenProviderInitializer) DefaultCapabilities(qwenEnableCompatible bool) string(ApiNameRetrieveBatch): qwenCompatibleRetrieveBatchPath, string(ApiNameQwenAsyncAIGC): qwenAsyncAIGCPath, string(ApiNameQwenAsyncTask): qwenAsyncTaskPath, - string(ApiNameQwenV1Rerank): qwenTextRerankPath, + string(ApiNameQwenV1Rerank): qwenCompatibleTextRerankPath, + string(ApiNameQwenV1Conversations): qwenCompatibleConversationsPath, string(ApiNameAnthropicMessages): qwenAnthropicMessagesPath, } } else { @@ -715,8 +718,11 @@ func (m *qwenProvider) GetApiName(path string) ApiName { return ApiNameQwenAsyncAIGC case strings.Contains(path, qwenAsyncTaskPath): return ApiNameQwenAsyncTask - case strings.Contains(path, qwenTextRerankPath): + case strings.Contains(path, qwenTextRerankPath), + strings.Contains(path, qwenCompatibleTextRerankPath): return ApiNameQwenV1Rerank + case strings.Contains(path, qwenCompatibleConversationsPath): + return ApiNameQwenV1Conversations default: return "" } diff --git a/plugins/wasm-go/extensions/ai-proxy/test/qwen.go b/plugins/wasm-go/extensions/ai-proxy/test/qwen.go index 4e8be4ad8..02eff6e37 100644 --- a/plugins/wasm-go/extensions/ai-proxy/test/qwen.go +++ b/plugins/wasm-go/extensions/ai-proxy/test/qwen.go @@ -366,6 +366,29 @@ func RunQwenOnHttpRequestHeadersTests(t *testing.T) { require.Contains(t, authValue, "sk-qwen-test123456789", "Authorization should contain qwen API token") }) + // 测试qwen请求头处理(reranks接口) + t.Run("qwen reranks request headers", func(t *testing.T) { + host, status := test.NewTestHost(basicQwenConfig) + defer host.Reset() + require.Equal(t, types.OnPluginStartStatusOK, status) + + action := host.CallOnHttpRequestHeaders([][2]string{ + {":authority", "example.com"}, + {":path", "/v1/reranks"}, + {":method", "POST"}, + {"Content-Type", "application/json"}, + }) + + require.Equal(t, types.HeaderStopIteration, action) + + requestHeaders := host.GetRequestHeaders() + require.NotNil(t, requestHeaders) + + pathValue, hasPath := test.GetHeaderValue(requestHeaders, ":path") + require.True(t, hasPath) + require.Contains(t, pathValue, "/api/v1/services/rerank/text-rerank/text-rerank", "Path should be converted to qwen rerank path") + }) + // 测试qwen自定义域名请求头处理 t.Run("qwen custom domain request headers", func(t *testing.T) { host, status := test.NewTestHost(qwenCustomDomainConfig) @@ -451,6 +474,52 @@ func RunQwenOnHttpRequestHeadersTests(t *testing.T) { require.True(t, hasPath) require.Contains(t, pathValue, "/compatible-mode/v1/responses", "Path should use compatible mode responses path") }) + + // 测试qwen兼容模式请求头处理(reranks接口) + t.Run("qwen compatible mode reranks request headers", func(t *testing.T) { + host, status := test.NewTestHost(qwenEnableCompatibleConfig) + defer host.Reset() + require.Equal(t, types.OnPluginStartStatusOK, status) + + action := host.CallOnHttpRequestHeaders([][2]string{ + {":authority", "example.com"}, + {":path", "/v1/reranks"}, + {":method", "POST"}, + {"Content-Type", "application/json"}, + }) + + require.Equal(t, types.HeaderStopIteration, action) + + requestHeaders := host.GetRequestHeaders() + require.NotNil(t, requestHeaders) + + pathValue, hasPath := test.GetHeaderValue(requestHeaders, ":path") + require.True(t, hasPath) + require.Contains(t, pathValue, "/compatible-api/v1/reranks", "Path should use compatible API reranks path") + }) + + // 测试qwen兼容模式请求头处理(conversations接口) + t.Run("qwen compatible mode conversations request headers", func(t *testing.T) { + host, status := test.NewTestHost(qwenEnableCompatibleConfig) + defer host.Reset() + require.Equal(t, types.OnPluginStartStatusOK, status) + + action := host.CallOnHttpRequestHeaders([][2]string{ + {":authority", "example.com"}, + {":path", "/v1/conversations"}, + {":method", "POST"}, + {"Content-Type", "application/json"}, + }) + + require.Equal(t, types.HeaderStopIteration, action) + + requestHeaders := host.GetRequestHeaders() + require.NotNil(t, requestHeaders) + + pathValue, hasPath := test.GetHeaderValue(requestHeaders, ":path") + require.True(t, hasPath) + require.Contains(t, pathValue, "/compatible-mode/v1/conversations", "Path should use compatible mode conversations path") + }) }) } @@ -583,6 +652,35 @@ func RunQwenOnHttpRequestBodyTests(t *testing.T) { require.True(t, hasFileLogs, "Should have file processing logs") }) + // 测试qwen请求体处理(reranks接口) + t.Run("qwen reranks request body", func(t *testing.T) { + host, status := test.NewTestHost(qwenMultiModelConfig) + defer host.Reset() + require.Equal(t, types.OnPluginStartStatusOK, status) + + host.CallOnHttpRequestHeaders([][2]string{ + {":authority", "example.com"}, + {":path", "/v1/reranks"}, + {":method", "POST"}, + {"Content-Type", "application/json"}, + }) + + requestBody := `{ + "model":"qwen3-rerank", + "documents":["doc1","doc2"], + "query":"test query", + "top_n":1 + }` + action := host.CallOnHttpRequestBody([]byte(requestBody)) + + require.Equal(t, types.ActionContinue, action) + + processedBody := host.GetRequestBody() + require.NotNil(t, processedBody) + require.Contains(t, string(processedBody), "qwen3-rerank", "Reranks request model should be preserved") + require.Contains(t, string(processedBody), "documents", "Reranks request documents should be preserved") + }) + // 测试qwen请求体处理(qwen-vl模型,多模态) t.Run("qwen qwen-vl model multimodal request body", func(t *testing.T) { host, status := test.NewTestHost(qwenMultiModelConfig) @@ -723,6 +821,63 @@ func RunQwenOnHttpRequestBodyTests(t *testing.T) { require.Contains(t, string(processedBody), "qwen-turbo", "Model name should be preserved in responses request") }) + // 测试qwen请求体处理(兼容模式 reranks接口) + t.Run("qwen compatible mode reranks request body", func(t *testing.T) { + host, status := test.NewTestHost(qwenEnableCompatibleConfig) + defer host.Reset() + require.Equal(t, types.OnPluginStartStatusOK, status) + + host.CallOnHttpRequestHeaders([][2]string{ + {":authority", "example.com"}, + {":path", "/v1/reranks"}, + {":method", "POST"}, + {"Content-Type", "application/json"}, + }) + + requestBody := `{ + "model":"qwen3-rerank", + "documents":["doc1","doc2"], + "query":"test query", + "top_n":1 + }` + action := host.CallOnHttpRequestBody([]byte(requestBody)) + + require.Equal(t, types.ActionContinue, action) + + processedBody := host.GetRequestBody() + require.NotNil(t, processedBody) + require.Contains(t, string(processedBody), "qwen-turbo", "Reranks request model should be mapped by wildcard") + require.Contains(t, string(processedBody), "documents", "Reranks request documents should be preserved") + }) + + // 测试qwen请求体处理(兼容模式 conversations接口) + t.Run("qwen compatible mode conversations request body", func(t *testing.T) { + host, status := test.NewTestHost(qwenEnableCompatibleConfig) + defer host.Reset() + require.Equal(t, types.OnPluginStartStatusOK, status) + + host.CallOnHttpRequestHeaders([][2]string{ + {":authority", "example.com"}, + {":path", "/v1/conversations"}, + {":method", "POST"}, + {"Content-Type", "application/json"}, + }) + + requestBody := `{ + "metadata":{"topic":"demo"}, + "items":[{"type":"message","role":"system","content":"test content"}] + }` + action := host.CallOnHttpRequestBody([]byte(requestBody)) + + require.Equal(t, types.ActionContinue, action) + + processedBody := host.GetRequestBody() + require.NotNil(t, processedBody) + require.Contains(t, string(processedBody), "\"metadata\"", "Conversations metadata should be preserved") + require.Contains(t, string(processedBody), "\"items\"", "Conversations items should be preserved") + require.NotContains(t, string(processedBody), "\"model\":", "Conversations request should not inject model field") + }) + // 测试qwen请求体处理(非兼容模式 responses接口应报不支持) t.Run("qwen non-compatible mode responses request body unsupported", func(t *testing.T) { host, status := test.NewTestHost(basicQwenConfig) @@ -755,9 +910,11 @@ func RunQwenOnHttpRequestBodyTests(t *testing.T) { // 覆盖 qwen.GetApiName 中以下分支: // - qwenCompatibleTextEmbeddingPath => ApiNameEmbeddings // - qwenCompatibleResponsesPath => ApiNameResponses + // - qwenCompatibleTextRerankPath => ApiNameQwenV1Rerank + // - qwenCompatibleConversationsPath => ApiNameQwenV1Conversations // - qwenAsyncAIGCPath => ApiNameQwenAsyncAIGC // - qwenAsyncTaskPath => ApiNameQwenAsyncTask - t.Run("qwen original protocol get api name coverage for compatible embeddings responses and async paths", func(t *testing.T) { + t.Run("qwen original protocol get api name coverage for compatible paths and async paths", func(t *testing.T) { cases := []struct { name string path string @@ -770,6 +927,14 @@ func RunQwenOnHttpRequestBodyTests(t *testing.T) { name: "compatible responses path", path: "/compatible-mode/v1/responses", }, + { + name: "compatible reranks path", + path: "/compatible-api/v1/reranks", + }, + { + name: "compatible conversations path", + path: "/compatible-mode/v1/conversations", + }, { name: "async aigc path", path: "/api/v1/services/aigc/custom-async-endpoint",