diff --git a/plugins/wasm-go/extensions/ai-proxy/main_test.go b/plugins/wasm-go/extensions/ai-proxy/main_test.go index 8851efa98..f5a5c0f20 100644 --- a/plugins/wasm-go/extensions/ai-proxy/main_test.go +++ b/plugins/wasm-go/extensions/ai-proxy/main_test.go @@ -106,6 +106,7 @@ func TestAzure(t *testing.T) { test.RunAzureOnHttpRequestBodyTests(t) test.RunAzureOnHttpResponseHeadersTests(t) test.RunAzureOnHttpResponseBodyTests(t) + test.RunAzureBasePathHandlingTests(t) } func TestFireworks(t *testing.T) { @@ -114,6 +115,10 @@ func TestFireworks(t *testing.T) { test.RunFireworksOnHttpRequestBodyTests(t) } +func TestMinimax(t *testing.T) { + test.RunMinimaxBasePathHandlingTests(t) +} + func TestUtil(t *testing.T) { test.RunMapRequestPathByCapabilityTests(t) } diff --git a/plugins/wasm-go/extensions/ai-proxy/provider/azure.go b/plugins/wasm-go/extensions/ai-proxy/provider/azure.go index ea763abf0..9b89acd2a 100644 --- a/plugins/wasm-go/extensions/ai-proxy/provider/azure.go +++ b/plugins/wasm-go/extensions/ai-proxy/provider/azure.go @@ -174,12 +174,14 @@ func (m *azureProvider) TransformRequestBody(ctx wrapper.HttpContext, apiName Ap } func (m *azureProvider) transformRequestPath(ctx wrapper.HttpContext, apiName ApiName) string { - originalPath := util.GetOriginalRequestPath() - + // When using original protocol, don't overwrite the path. + // This ensures basePathHandling works correctly even in TransformRequestBody stage. if m.config.IsOriginal() { - return originalPath + return "" } + originalPath := util.GetOriginalRequestPath() + if m.serviceUrlType == azureServiceUrlTypeFull { log.Debugf("azureProvider: use configured path %s", m.serviceUrlFullPath) return m.serviceUrlFullPath diff --git a/plugins/wasm-go/extensions/ai-proxy/provider/minimax.go b/plugins/wasm-go/extensions/ai-proxy/provider/minimax.go index 9889f11f0..0cbf64315 100644 --- a/plugins/wasm-go/extensions/ai-proxy/provider/minimax.go +++ b/plugins/wasm-go/extensions/ai-proxy/provider/minimax.go @@ -106,8 +106,11 @@ func (m *minimaxProvider) handleRequestBodyByChatCompletionPro(body []byte) (typ } // Map the model and rewrite the request path. + // When using original protocol, don't overwrite the path to ensure basePathHandling works correctly. request.Model = getMappedModel(request.Model, m.config.modelMapping) - _ = util.OverwriteRequestPath(fmt.Sprintf("%s?GroupId=%s", minimaxChatCompletionProPath, m.config.minimaxGroupId)) + if !m.config.IsOriginal() { + _ = util.OverwriteRequestPath(fmt.Sprintf("%s?GroupId=%s", minimaxChatCompletionProPath, m.config.minimaxGroupId)) + } if m.config.context == nil { minimaxRequest := m.buildMinimaxChatCompletionProRequest(request, "") diff --git a/plugins/wasm-go/extensions/ai-proxy/provider/provider.go b/plugins/wasm-go/extensions/ai-proxy/provider/provider.go index c77cb17cc..faf7cb125 100644 --- a/plugins/wasm-go/extensions/ai-proxy/provider/provider.go +++ b/plugins/wasm-go/extensions/ai-proxy/provider/provider.go @@ -973,12 +973,33 @@ func (c *ProviderConfig) handleRequestBody( func (c *ProviderConfig) handleRequestHeaders(provider Provider, ctx wrapper.HttpContext, apiName ApiName) { headers := util.GetRequestHeaders() originPath := headers.Get(":path") + + // Record the path after removePrefix processing + var removePrefixPath string if c.basePath != "" && c.basePathHandling == basePathHandlingRemovePrefix { - headers.Set(":path", strings.TrimPrefix(originPath, c.basePath)) + removePrefixPath = strings.TrimPrefix(originPath, c.basePath) + headers.Set(":path", removePrefixPath) } + if handler, ok := provider.(TransformRequestHeadersHandler); ok { handler.TransformRequestHeaders(ctx, apiName, headers) } + + // When using original protocol with removePrefix, restore the basePath-processed path. + // This ensures basePathHandling works correctly even when TransformRequestHeaders + // overwrites the path (which most providers do). + // + // TODO: Most providers (OpenAI, vLLM, DeepSeek, Claude, etc.) unconditionally overwrite + // the path in TransformRequestHeaders without checking IsOriginal(). Ideally, each provider + // should check IsOriginal() before overwriting the path (like Qwen does). Once all providers + // are updated to handle protocol correctly, this workaround can be removed. + // Affected providers: OpenAI, vLLM, ZhipuAI, Moonshot, Longcat, DeepSeek, Azure, Yi, + // TogetherAI, Stepfun, Ollama, Hunyuan, GitHub, Doubao, Cohere, Baichuan, AI360, Claude, + // Groq, Grok, Spark, Fireworks, Cloudflare, Baidu, OpenRouter, DeepL (24+ providers) + if c.IsOriginal() && removePrefixPath != "" { + headers.Set(":path", removePrefixPath) + } + if c.basePath != "" && c.basePathHandling == basePathHandlingPrepend && !strings.HasPrefix(headers.Get(":path"), c.basePath) { headers.Set(":path", path.Join(c.basePath, headers.Get(":path"))) } diff --git a/plugins/wasm-go/extensions/ai-proxy/test/azure.go b/plugins/wasm-go/extensions/ai-proxy/test/azure.go index 53da958f1..51fca4346 100644 --- a/plugins/wasm-go/extensions/ai-proxy/test/azure.go +++ b/plugins/wasm-go/extensions/ai-proxy/test/azure.go @@ -160,6 +160,59 @@ var azureResponseAPIConfig = func() json.RawMessage { return data }() +// 测试配置:Azure OpenAI basePath移除 + original协议 +var azureBasePathRemovePrefixOriginalConfig = func() json.RawMessage { + data, _ := json.Marshal(map[string]interface{}{ + "provider": map[string]interface{}{ + "type": "azure", + "apiTokens": []string{ + "sk-azure-basepath-original", + }, + "azureServiceUrl": "https://basepath-test.openai.azure.com/openai/deployments/gpt-4/chat/completions?api-version=2024-02-15-preview", + "basePath": "/azure-gpt4", + "basePathHandling": "removePrefix", + "protocol": "original", + }, + }) + return data +}() + +// 测试配置:Azure OpenAI basePath移除 + openai协议 +var azureBasePathRemovePrefixOpenAIConfig = func() json.RawMessage { + data, _ := json.Marshal(map[string]interface{}{ + "provider": map[string]interface{}{ + "type": "azure", + "apiTokens": []string{ + "sk-azure-basepath-openai", + }, + "azureServiceUrl": "https://basepath-test.openai.azure.com/openai/deployments/gpt-4/chat/completions?api-version=2024-02-15-preview", + "basePath": "/azure-gpt4", + "basePathHandling": "removePrefix", + "modelMapping": map[string]string{ + "*": "gpt-4", + }, + }, + }) + return data +}() + +// 测试配置:Azure OpenAI basePath prepend + original协议 +var azureBasePathPrependOriginalConfig = func() json.RawMessage { + data, _ := json.Marshal(map[string]interface{}{ + "provider": map[string]interface{}{ + "type": "azure", + "apiTokens": []string{ + "sk-azure-prepend-original", + }, + "azureServiceUrl": "https://prepend-test.openai.azure.com/openai/deployments/gpt-4/chat/completions?api-version=2024-02-15-preview", + "basePath": "/api/v1", + "basePathHandling": "prepend", + "protocol": "original", + }, + }) + return data +}() + func RunAzureParseConfigTests(t *testing.T) { test.RunGoTest(t, func(t *testing.T) { // 测试基本Azure OpenAI配置解析 @@ -682,3 +735,218 @@ func RunAzureOnHttpResponseBodyTests(t *testing.T) { }) }) } + +// RunAzureBasePathHandlingTests 测试 basePath 处理在不同协议下的行为 +func RunAzureBasePathHandlingTests(t *testing.T) { + test.RunTest(t, func(t *testing.T) { + // 核心用例:测试 basePath removePrefix 在 original 协议下能正常工作 + // 重要:此测试验证在 TransformRequestBody 阶段后 path 仍然保持正确 + // 之前的 bug 是 transformRequestPath 在 IsOriginal() 时返回 originalPath, + // 导致在 Body 阶段 path 被重新覆盖为包含 basePath 的原始路径 + t.Run("azure basePath removePrefix with original protocol after body processing", func(t *testing.T) { + host, status := test.NewTestHost(azureBasePathRemovePrefixOriginalConfig) + defer host.Reset() + require.Equal(t, types.OnPluginStartStatusOK, status) + + // 模拟带有 basePath 前缀的请求 + action := host.CallOnHttpRequestHeaders([][2]string{ + {":authority", "example.com"}, + {":path", "/azure-gpt4/v1/chat/completions"}, + {":method", "POST"}, + {"Content-Type", "application/json"}, + }) + require.Equal(t, types.HeaderStopIteration, action) + + // 在 Headers 阶段后验证 path(此时 handleRequestHeaders 已执行) + headersAfterHeaderStage := host.GetRequestHeaders() + pathAfterHeaders, _ := test.GetHeaderValue(headersAfterHeaderStage, ":path") + // Headers 阶段后,basePath 应该已被移除 + require.NotContains(t, pathAfterHeaders, "/azure-gpt4", + "After headers stage: basePath should be removed") + + // 执行 Body 阶段(此时 TransformRequestBody 会被调用) + requestBody := `{"model": "gpt-4", "messages": [{"role": "user", "content": "Hello"}]}` + action = host.CallOnHttpRequestBody([]byte(requestBody)) + require.Equal(t, types.ActionContinue, action) + + // 核心验证:在 Body 阶段后验证 path + // 这是关键测试点:确保 TransformRequestBody 中的 transformRequestPath + // 不会将 path 重新覆盖为包含 basePath 的原始路径 + requestHeaders := host.GetRequestHeaders() + require.NotNil(t, requestHeaders) + + pathValue, hasPath := test.GetHeaderValue(requestHeaders, ":path") + require.True(t, hasPath, "Path header should exist") + // basePath "/azure-gpt4" 不应该出现在最终路径中 + require.NotContains(t, pathValue, "/azure-gpt4", + "After body stage: basePath should still be removed (not restored by TransformRequestBody)") + // 路径应该是移除 basePath 后的结果 + require.Equal(t, "/v1/chat/completions", pathValue, + "Path should be the original path without basePath after full request processing") + + // 验证 Host 被正确设置 + hostValue, hasHost := test.GetHeaderValue(requestHeaders, ":authority") + require.True(t, hasHost, "Host header should exist") + require.Equal(t, "basepath-test.openai.azure.com", hostValue) + + // 验证 api-key 被正确设置 + apiKeyValue, hasApiKey := test.GetHeaderValue(requestHeaders, "api-key") + require.True(t, hasApiKey, "api-key header should exist") + require.Equal(t, "sk-azure-basepath-original", apiKeyValue) + }) + + // 测试 basePath removePrefix 在 openai 协议下的行为 + // 在 openai 协议下,path 会被转换为 Azure 格式,但 basePath 仍然应该被移除 + t.Run("azure basePath removePrefix with openai protocol after body processing", func(t *testing.T) { + host, status := test.NewTestHost(azureBasePathRemovePrefixOpenAIConfig) + defer host.Reset() + require.Equal(t, types.OnPluginStartStatusOK, status) + + // 模拟带有 basePath 前缀的请求 + action := host.CallOnHttpRequestHeaders([][2]string{ + {":authority", "example.com"}, + {":path", "/azure-gpt4/v1/chat/completions"}, + {":method", "POST"}, + {"Content-Type", "application/json"}, + }) + require.Equal(t, types.HeaderStopIteration, action) + + // 执行 Body 阶段(TransformRequestBody 会被调用) + requestBody := `{"model": "gpt-4", "messages": [{"role": "user", "content": "Hello"}]}` + action = host.CallOnHttpRequestBody([]byte(requestBody)) + require.Equal(t, types.ActionContinue, action) + + // 在 Body 阶段后验证请求头 + requestHeaders := host.GetRequestHeaders() + require.NotNil(t, requestHeaders) + + // basePath 应该被移除,路径会被转换为 Azure 路径格式 + pathValue, hasPath := test.GetHeaderValue(requestHeaders, ":path") + require.True(t, hasPath, "Path header should exist") + // basePath "/azure-gpt4" 不应该出现在最终路径中 + require.NotContains(t, pathValue, "/azure-gpt4", + "After body stage: basePath should be removed from path") + // 在 openai 协议下,路径会被转换为 Azure 的路径格式 + require.Contains(t, pathValue, "/openai/deployments/gpt-4/chat/completions", + "Path should be transformed to Azure format") + require.Contains(t, pathValue, "api-version=2024-02-15-preview", + "Path should contain API version") + }) + + // 测试 basePath prepend 在 original 协议下能正常工作 + // 验证在 Body 阶段后 prepend 的 basePath 仍然保持 + t.Run("azure basePath prepend with original protocol after body processing", func(t *testing.T) { + host, status := test.NewTestHost(azureBasePathPrependOriginalConfig) + defer host.Reset() + require.Equal(t, types.OnPluginStartStatusOK, status) + + // 模拟不带 basePath 的请求 + action := host.CallOnHttpRequestHeaders([][2]string{ + {":authority", "example.com"}, + {":path", "/chat/completions"}, + {":method", "POST"}, + {"Content-Type", "application/json"}, + }) + require.Equal(t, types.HeaderStopIteration, action) + + // 执行 Body 阶段 + requestBody := `{"model": "gpt-4", "messages": [{"role": "user", "content": "Hello"}]}` + action = host.CallOnHttpRequestBody([]byte(requestBody)) + require.Equal(t, types.ActionContinue, action) + + // 在 Body 阶段后验证请求头 + requestHeaders := host.GetRequestHeaders() + require.NotNil(t, requestHeaders) + + // 验证 basePath 被正确添加且在 Body 阶段后保持 + pathValue, hasPath := test.GetHeaderValue(requestHeaders, ":path") + require.True(t, hasPath, "Path header should exist") + // basePath "/api/v1" 应该被添加到路径前面 + require.True(t, strings.HasPrefix(pathValue, "/api/v1"), + "After body stage: Path should still start with prepended basePath") + }) + + // 测试 original 协议下请求体不被修改,同时验证 path 处理 + t.Run("azure original protocol preserves request body and path", func(t *testing.T) { + host, status := test.NewTestHost(azureBasePathRemovePrefixOriginalConfig) + defer host.Reset() + require.Equal(t, types.OnPluginStartStatusOK, status) + + // 设置请求头 + action := host.CallOnHttpRequestHeaders([][2]string{ + {":authority", "example.com"}, + {":path", "/azure-gpt4/v1/chat/completions"}, + {":method", "POST"}, + {"Content-Type", "application/json"}, + }) + require.Equal(t, types.HeaderStopIteration, action) + + // 设置请求体(包含自定义字段) + requestBody := `{ + "model": "custom-model-name", + "messages": [{"role": "user", "content": "Hello"}], + "custom_field": "custom_value" + }` + action = host.CallOnHttpRequestBody([]byte(requestBody)) + require.Equal(t, types.ActionContinue, action) + + // 验证请求体被保持原样 + transformedBody := host.GetRequestBody() + require.NotNil(t, transformedBody) + + var bodyMap map[string]interface{} + err := json.Unmarshal(transformedBody, &bodyMap) + require.NoError(t, err) + + // model 应该保持原样(original 协议不做模型映射) + model, exists := bodyMap["model"] + require.True(t, exists, "Model should exist") + require.Equal(t, "custom-model-name", model, "Model should remain unchanged") + + // 自定义字段应该保持原样 + customField, exists := bodyMap["custom_field"] + require.True(t, exists, "Custom field should exist") + require.Equal(t, "custom_value", customField, "Custom field should remain unchanged") + + // 同时验证 path 在 Body 阶段后仍然正确 + requestHeaders := host.GetRequestHeaders() + pathValue, hasPath := test.GetHeaderValue(requestHeaders, ":path") + require.True(t, hasPath, "Path header should exist") + require.NotContains(t, pathValue, "/azure-gpt4", + "After body stage: basePath should be removed") + require.Equal(t, "/v1/chat/completions", pathValue, + "Path should be correct after body processing") + }) + + // 测试无 basePath 前缀的请求(removePrefix 配置不影响) + // 验证在 Body 阶段后 path 仍然保持正确 + t.Run("azure request without basePath prefix after body processing", func(t *testing.T) { + host, status := test.NewTestHost(azureBasePathRemovePrefixOriginalConfig) + defer host.Reset() + require.Equal(t, types.OnPluginStartStatusOK, status) + + // 模拟不带 basePath 前缀的请求 + action := host.CallOnHttpRequestHeaders([][2]string{ + {":authority", "example.com"}, + {":path", "/v1/chat/completions"}, + {":method", "POST"}, + {"Content-Type", "application/json"}, + }) + require.Equal(t, types.HeaderStopIteration, action) + + // 执行 Body 阶段 + requestBody := `{"model": "gpt-4", "messages": [{"role": "user", "content": "Hello"}]}` + action = host.CallOnHttpRequestBody([]byte(requestBody)) + require.Equal(t, types.ActionContinue, action) + + // 在 Body 阶段后验证请求头 + requestHeaders := host.GetRequestHeaders() + pathValue, hasPath := test.GetHeaderValue(requestHeaders, ":path") + require.True(t, hasPath, "Path header should exist") + // 路径应该保持原样(没有 basePath 前缀时,removePrefix 不会改变 path) + // 同时验证 TransformRequestBody 没有覆盖 path + require.Equal(t, "/v1/chat/completions", pathValue, + "After body stage: Path should remain unchanged when no basePath prefix") + }) + }) +} diff --git a/plugins/wasm-go/extensions/ai-proxy/test/minimax.go b/plugins/wasm-go/extensions/ai-proxy/test/minimax.go new file mode 100644 index 000000000..997556d91 --- /dev/null +++ b/plugins/wasm-go/extensions/ai-proxy/test/minimax.go @@ -0,0 +1,251 @@ +package test + +import ( + "encoding/json" + "strings" + "testing" + + "github.com/higress-group/proxy-wasm-go-sdk/proxywasm/types" + "github.com/higress-group/wasm-go/pkg/test" + "github.com/stretchr/testify/require" +) + +// 测试配置:Minimax Pro API + basePath removePrefix + original 协议 +var minimaxProBasePathRemovePrefixOriginalConfig = func() json.RawMessage { + data, _ := json.Marshal(map[string]interface{}{ + "provider": map[string]interface{}{ + "type": "minimax", + "apiTokens": []string{ + "sk-minimax-test", + }, + "minimaxApiType": "pro", + "minimaxGroupId": "test-group-id", + "basePath": "/minimax-api", + "basePathHandling": "removePrefix", + "protocol": "original", + }, + }) + return data +}() + +// 测试配置:Minimax Pro API + basePath removePrefix + 默认协议(openai) +var minimaxProBasePathRemovePrefixOpenAIConfig = func() json.RawMessage { + data, _ := json.Marshal(map[string]interface{}{ + "provider": map[string]interface{}{ + "type": "minimax", + "apiTokens": []string{ + "sk-minimax-openai", + }, + "minimaxApiType": "pro", + "minimaxGroupId": "test-group-id", + "basePath": "/minimax-api", + "basePathHandling": "removePrefix", + }, + }) + return data +}() + +// 测试配置:Minimax V2 API + basePath removePrefix + original 协议 +var minimaxV2BasePathRemovePrefixOriginalConfig = func() json.RawMessage { + data, _ := json.Marshal(map[string]interface{}{ + "provider": map[string]interface{}{ + "type": "minimax", + "apiTokens": []string{ + "sk-minimax-v2", + }, + "minimaxApiType": "v2", + "basePath": "/minimax-v2", + "basePathHandling": "removePrefix", + "protocol": "original", + }, + }) + return data +}() + +// RunMinimaxBasePathHandlingTests 测试 Minimax basePath 处理在不同协议下的行为 +func RunMinimaxBasePathHandlingTests(t *testing.T) { + test.RunTest(t, func(t *testing.T) { + // 核心用例:测试 Minimax Pro API + basePath removePrefix + original 协议 + // 重要:此测试验证在 handleRequestBodyByChatCompletionPro 阶段后 path 仍然保持正确 + // 之前的 bug 是 handleRequestBodyByChatCompletionPro 无条件覆盖 path, + // 导致在 Body 阶段 path 被重新覆盖为 minimaxChatCompletionProPath + t.Run("minimax pro basePath removePrefix with original protocol after body processing", func(t *testing.T) { + host, status := test.NewTestHost(minimaxProBasePathRemovePrefixOriginalConfig) + defer host.Reset() + require.Equal(t, types.OnPluginStartStatusOK, status) + + // 模拟带有 basePath 前缀的请求 + action := host.CallOnHttpRequestHeaders([][2]string{ + {":authority", "example.com"}, + {":path", "/minimax-api/v1/chat/completions"}, + {":method", "POST"}, + {"Content-Type", "application/json"}, + }) + require.Equal(t, types.HeaderStopIteration, action) + + // 在 Headers 阶段后验证 path(此时 handleRequestHeaders 已执行) + headersAfterHeaderStage := host.GetRequestHeaders() + pathAfterHeaders, _ := test.GetHeaderValue(headersAfterHeaderStage, ":path") + // Headers 阶段后,basePath 应该已被移除 + require.NotContains(t, pathAfterHeaders, "/minimax-api", + "After headers stage: basePath should be removed") + + // 执行 Body 阶段(此时 handleRequestBodyByChatCompletionPro 会被调用) + requestBody := `{"model": "abab5.5-chat", "messages": [{"role": "user", "content": "Hello"}]}` + action = host.CallOnHttpRequestBody([]byte(requestBody)) + require.Equal(t, types.ActionContinue, action) + + // 核心验证:在 Body 阶段后验证 path + // 这是关键测试点:确保 handleRequestBodyByChatCompletionPro + // 不会将 path 重新覆盖为 minimaxChatCompletionProPath + requestHeaders := host.GetRequestHeaders() + require.NotNil(t, requestHeaders) + + pathValue, hasPath := test.GetHeaderValue(requestHeaders, ":path") + require.True(t, hasPath, "Path header should exist") + // basePath "/minimax-api" 不应该出现在最终路径中 + require.NotContains(t, pathValue, "/minimax-api", + "After body stage: basePath should still be removed") + // original 协议下,path 不应该被覆盖为 minimaxChatCompletionProPath + require.NotContains(t, pathValue, "chatcompletion_pro", + "With original protocol: path should not be overwritten to minimax pro path") + // 路径应该是移除 basePath 后的结果 + require.Equal(t, "/v1/chat/completions", pathValue, + "Path should be the original path without basePath after full request processing") + + // 验证 Host 被正确设置 + hostValue, hasHost := test.GetHeaderValue(requestHeaders, ":authority") + require.True(t, hasHost, "Host header should exist") + require.Equal(t, "api.minimax.chat", hostValue) + + // 验证 Authorization 被正确设置 + authValue, hasAuth := test.GetHeaderValue(requestHeaders, "Authorization") + require.True(t, hasAuth, "Authorization header should exist") + require.Equal(t, "Bearer sk-minimax-test", authValue) + }) + + // 测试 Minimax Pro API + basePath removePrefix + 默认协议(openai) + // 在 openai 协议下,path 应该被覆盖为 minimaxChatCompletionProPath + t.Run("minimax pro basePath removePrefix with openai protocol after body processing", func(t *testing.T) { + host, status := test.NewTestHost(minimaxProBasePathRemovePrefixOpenAIConfig) + defer host.Reset() + require.Equal(t, types.OnPluginStartStatusOK, status) + + // 模拟带有 basePath 前缀的请求 + action := host.CallOnHttpRequestHeaders([][2]string{ + {":authority", "example.com"}, + {":path", "/minimax-api/v1/chat/completions"}, + {":method", "POST"}, + {"Content-Type", "application/json"}, + }) + require.Equal(t, types.HeaderStopIteration, action) + + // 执行 Body 阶段 + requestBody := `{"model": "abab5.5-chat", "messages": [{"role": "user", "content": "Hello"}]}` + action = host.CallOnHttpRequestBody([]byte(requestBody)) + require.Equal(t, types.ActionContinue, action) + + // 在 Body 阶段后验证请求头 + requestHeaders := host.GetRequestHeaders() + require.NotNil(t, requestHeaders) + + pathValue, hasPath := test.GetHeaderValue(requestHeaders, ":path") + require.True(t, hasPath, "Path header should exist") + // basePath "/minimax-api" 不应该出现在最终路径中 + require.NotContains(t, pathValue, "/minimax-api", + "After body stage: basePath should be removed from path") + // 在 openai 协议下,path 应该被覆盖为 minimaxChatCompletionProPath + require.True(t, strings.Contains(pathValue, "chatcompletion_pro"), + "With openai protocol: path should be overwritten to minimax pro path") + require.Contains(t, pathValue, "GroupId=test-group-id", + "Path should contain GroupId parameter") + }) + + // 测试 Minimax V2 API + basePath removePrefix + original 协议 + // V2 API 使用 handleRequestBody 而不是 handleRequestBodyByChatCompletionPro + t.Run("minimax v2 basePath removePrefix with original protocol after body processing", func(t *testing.T) { + host, status := test.NewTestHost(minimaxV2BasePathRemovePrefixOriginalConfig) + defer host.Reset() + require.Equal(t, types.OnPluginStartStatusOK, status) + + // 模拟带有 basePath 前缀的请求 + action := host.CallOnHttpRequestHeaders([][2]string{ + {":authority", "example.com"}, + {":path", "/minimax-v2/v1/chat/completions"}, + {":method", "POST"}, + {"Content-Type", "application/json"}, + }) + require.Equal(t, types.HeaderStopIteration, action) + + // 执行 Body 阶段 + requestBody := `{"model": "abab5.5-chat", "messages": [{"role": "user", "content": "Hello"}]}` + action = host.CallOnHttpRequestBody([]byte(requestBody)) + require.Equal(t, types.ActionContinue, action) + + // 在 Body 阶段后验证请求头 + requestHeaders := host.GetRequestHeaders() + require.NotNil(t, requestHeaders) + + pathValue, hasPath := test.GetHeaderValue(requestHeaders, ":path") + require.True(t, hasPath, "Path header should exist") + // basePath "/minimax-v2" 不应该出现在最终路径中 + require.NotContains(t, pathValue, "/minimax-v2", + "After body stage: basePath should be removed from path") + // 路径应该是移除 basePath 后的结果 + require.Equal(t, "/v1/chat/completions", pathValue, + "Path should be the original path without basePath") + }) + + // 测试 original 协议下请求体保持原样 + t.Run("minimax pro original protocol preserves request body and path", func(t *testing.T) { + host, status := test.NewTestHost(minimaxProBasePathRemovePrefixOriginalConfig) + defer host.Reset() + require.Equal(t, types.OnPluginStartStatusOK, status) + + // 设置请求头 + action := host.CallOnHttpRequestHeaders([][2]string{ + {":authority", "example.com"}, + {":path", "/minimax-api/v1/chat/completions"}, + {":method", "POST"}, + {"Content-Type", "application/json"}, + }) + require.Equal(t, types.HeaderStopIteration, action) + + // 设置请求体(包含自定义字段) + requestBody := `{ + "model": "custom-model", + "messages": [{"role": "user", "content": "Hello"}], + "custom_field": "custom_value" + }` + action = host.CallOnHttpRequestBody([]byte(requestBody)) + require.Equal(t, types.ActionContinue, action) + + // 验证请求体被保持原样 + transformedBody := host.GetRequestBody() + require.NotNil(t, transformedBody) + + var bodyMap map[string]interface{} + err := json.Unmarshal(transformedBody, &bodyMap) + require.NoError(t, err) + + // model 应该保持原样(original 协议不做模型映射) + model, exists := bodyMap["model"] + require.True(t, exists, "Model should exist") + require.Equal(t, "custom-model", model, "Model should remain unchanged") + + // 自定义字段应该保持原样 + customField, exists := bodyMap["custom_field"] + require.True(t, exists, "Custom field should exist") + require.Equal(t, "custom_value", customField, "Custom field should remain unchanged") + + // 同时验证 path 在 Body 阶段后仍然正确 + requestHeaders := host.GetRequestHeaders() + pathValue, hasPath := test.GetHeaderValue(requestHeaders, ":path") + require.True(t, hasPath, "Path header should exist") + require.NotContains(t, pathValue, "/minimax-api", + "After body stage: basePath should be removed") + require.Equal(t, "/v1/chat/completions", pathValue, + "Path should be correct after body processing") + }) + }) +}