From 045238944dddc9a4dec943506a9239f08a7049a2 Mon Sep 17 00:00:00 2001 From: woody Date: Thu, 19 Mar 2026 19:25:55 +0800 Subject: [PATCH] Feat/new api path support (#3620) --- plugins/wasm-go/extensions/ai-proxy/main.go | 3 + .../wasm-go/extensions/ai-proxy/main_test.go | 10 + .../extensions/ai-proxy/provider/openai.go | 6 + .../extensions/ai-proxy/provider/provider.go | 10 + .../extensions/ai-proxy/provider/qwen.go | 4 + .../extensions/ai-proxy/test/api_paths.go | 116 ++++++++++ .../extensions/ai-proxy/test/openai.go | 133 ++++++++++++ .../wasm-go/extensions/ai-proxy/test/qwen.go | 199 ++++++++++++++++++ 8 files changed, 481 insertions(+) create mode 100644 plugins/wasm-go/extensions/ai-proxy/test/api_paths.go diff --git a/plugins/wasm-go/extensions/ai-proxy/main.go b/plugins/wasm-go/extensions/ai-proxy/main.go index a1c213f99..1e2e574aa 100644 --- a/plugins/wasm-go/extensions/ai-proxy/main.go +++ b/plugins/wasm-go/extensions/ai-proxy/main.go @@ -52,6 +52,9 @@ var ( {provider.PathOpenAICompletions, provider.ApiNameCompletion}, {provider.PathOpenAIEmbeddings, provider.ApiNameEmbeddings}, {provider.PathOpenAIAudioSpeech, provider.ApiNameAudioSpeech}, + {provider.PathOpenAIAudioTranscriptions, provider.ApiNameAudioTranscription}, + {provider.PathOpenAIAudioTranslations, provider.ApiNameAudioTranslation}, + {provider.PathOpenAIRealtime, provider.ApiNameRealtime}, {provider.PathOpenAIImageGeneration, provider.ApiNameImageGeneration}, {provider.PathOpenAIImageVariation, provider.ApiNameImageVariation}, {provider.PathOpenAIImageEdit, provider.ApiNameImageEdit}, diff --git a/plugins/wasm-go/extensions/ai-proxy/main_test.go b/plugins/wasm-go/extensions/ai-proxy/main_test.go index 1b44bee5c..e3ef5842f 100644 --- a/plugins/wasm-go/extensions/ai-proxy/main_test.go +++ b/plugins/wasm-go/extensions/ai-proxy/main_test.go @@ -18,6 +18,12 @@ func Test_getApiName(t *testing.T) { {"openai completions", "/v1/completions", provider.ApiNameCompletion}, {"openai embeddings", "/v1/embeddings", provider.ApiNameEmbeddings}, {"openai audio speech", "/v1/audio/speech", provider.ApiNameAudioSpeech}, + {"openai audio transcriptions", "/v1/audio/transcriptions", provider.ApiNameAudioTranscription}, + {"openai audio transcriptions with prefix", "/proxy/v1/audio/transcriptions", provider.ApiNameAudioTranscription}, + {"openai audio translations", "/v1/audio/translations", provider.ApiNameAudioTranslation}, + {"openai realtime", "/v1/realtime", provider.ApiNameRealtime}, + {"openai realtime with prefix", "/proxy/v1/realtime", provider.ApiNameRealtime}, + {"openai realtime with trailing slash", "/v1/realtime/", ""}, {"openai image generation", "/v1/images/generations", provider.ApiNameImageGeneration}, {"openai image variation", "/v1/images/variations", provider.ApiNameImageVariation}, {"openai image edit", "/v1/images/edits", provider.ApiNameImageEdit}, @@ -171,6 +177,10 @@ func TestUtil(t *testing.T) { test.RunMapRequestPathByCapabilityTests(t) } +func TestApiPathRegression(t *testing.T) { + test.RunApiPathRegressionTests(t) +} + func TestGeneric(t *testing.T) { test.RunGenericParseConfigTests(t) test.RunGenericOnHttpRequestHeadersTests(t) diff --git a/plugins/wasm-go/extensions/ai-proxy/provider/openai.go b/plugins/wasm-go/extensions/ai-proxy/provider/openai.go index a96085efc..58f7ea8b6 100644 --- a/plugins/wasm-go/extensions/ai-proxy/provider/openai.go +++ b/plugins/wasm-go/extensions/ai-proxy/provider/openai.go @@ -34,6 +34,9 @@ func (m *openaiProviderInitializer) DefaultCapabilities() map[string]string { string(ApiNameImageEdit): PathOpenAIImageEdit, string(ApiNameImageVariation): PathOpenAIImageVariation, string(ApiNameAudioSpeech): PathOpenAIAudioSpeech, + string(ApiNameAudioTranscription): PathOpenAIAudioTranscriptions, + string(ApiNameAudioTranslation): PathOpenAIAudioTranslations, + string(ApiNameRealtime): PathOpenAIRealtime, string(ApiNameModels): PathOpenAIModels, string(ApiNameFiles): PathOpenAIFiles, string(ApiNameRetrieveFile): PathOpenAIRetrieveFile, @@ -63,6 +66,8 @@ func isDirectPath(path string) bool { return strings.HasSuffix(path, "/completions") || strings.HasSuffix(path, "/embeddings") || strings.HasSuffix(path, "/audio/speech") || + strings.HasSuffix(path, "/audio/transcriptions") || + strings.HasSuffix(path, "/audio/translations") || strings.HasSuffix(path, "/images/generations") || strings.HasSuffix(path, "/images/variations") || strings.HasSuffix(path, "/images/edits") || @@ -70,6 +75,7 @@ func isDirectPath(path string) bool { strings.HasSuffix(path, "/responses") || strings.HasSuffix(path, "/fine_tuning/jobs") || strings.HasSuffix(path, "/fine_tuning/checkpoints") || + strings.HasSuffix(path, "/realtime") || strings.HasSuffix(path, "/videos") } diff --git a/plugins/wasm-go/extensions/ai-proxy/provider/provider.go b/plugins/wasm-go/extensions/ai-proxy/provider/provider.go index 80878c883..fec47e148 100644 --- a/plugins/wasm-go/extensions/ai-proxy/provider/provider.go +++ b/plugins/wasm-go/extensions/ai-proxy/provider/provider.go @@ -41,6 +41,9 @@ const ( ApiNameImageEdit ApiName = "openai/v1/imageedit" ApiNameImageVariation ApiName = "openai/v1/imagevariation" ApiNameAudioSpeech ApiName = "openai/v1/audiospeech" + ApiNameAudioTranscription ApiName = "openai/v1/audiotranscription" + ApiNameAudioTranslation ApiName = "openai/v1/audiotranslation" + ApiNameRealtime ApiName = "openai/v1/realtime" ApiNameFiles ApiName = "openai/v1/files" ApiNameRetrieveFile ApiName = "openai/v1/retrievefile" ApiNameRetrieveFileContent ApiName = "openai/v1/retrievefilecontent" @@ -90,6 +93,9 @@ const ( PathOpenAIImageEdit = "/v1/images/edits" PathOpenAIImageVariation = "/v1/images/variations" PathOpenAIAudioSpeech = "/v1/audio/speech" + PathOpenAIAudioTranscriptions = "/v1/audio/transcriptions" + PathOpenAIAudioTranslations = "/v1/audio/translations" + PathOpenAIRealtime = "/v1/realtime" PathOpenAIResponses = "/v1/responses" PathOpenAIFineTuningJobs = "/v1/fine_tuning/jobs" PathOpenAIRetrieveFineTuningJob = "/v1/fine_tuning/jobs/{fine_tuning_job_id}" @@ -662,6 +668,10 @@ func (c *ProviderConfig) FromJson(json gjson.Result) { string(ApiNameImageVariation), string(ApiNameImageEdit), string(ApiNameAudioSpeech), + string(ApiNameAudioTranscription), + string(ApiNameAudioTranslation), + string(ApiNameRealtime), + string(ApiNameResponses), string(ApiNameCohereV1Rerank), string(ApiNameVideos), string(ApiNameRetrieveVideo), diff --git a/plugins/wasm-go/extensions/ai-proxy/provider/qwen.go b/plugins/wasm-go/extensions/ai-proxy/provider/qwen.go index 82ca16c5e..a557147dc 100644 --- a/plugins/wasm-go/extensions/ai-proxy/provider/qwen.go +++ b/plugins/wasm-go/extensions/ai-proxy/provider/qwen.go @@ -30,6 +30,7 @@ const ( qwenCompatibleChatCompletionPath = "/compatible-mode/v1/chat/completions" qwenCompatibleCompletionsPath = "/compatible-mode/v1/completions" qwenCompatibleTextEmbeddingPath = "/compatible-mode/v1/embeddings" + qwenCompatibleResponsesPath = "/api/v2/apps/protocols/compatible-mode/v1/responses" qwenCompatibleFilesPath = "/compatible-mode/v1/files" qwenCompatibleRetrieveFilePath = "/compatible-mode/v1/files/{file_id}" qwenCompatibleRetrieveFileContentPath = "/compatible-mode/v1/files/{file_id}/content" @@ -69,6 +70,7 @@ func (m *qwenProviderInitializer) DefaultCapabilities(qwenEnableCompatible bool) string(ApiNameChatCompletion): qwenCompatibleChatCompletionPath, string(ApiNameEmbeddings): qwenCompatibleTextEmbeddingPath, string(ApiNameCompletion): qwenCompatibleCompletionsPath, + string(ApiNameResponses): qwenCompatibleResponsesPath, string(ApiNameFiles): qwenCompatibleFilesPath, string(ApiNameRetrieveFile): qwenCompatibleRetrieveFilePath, string(ApiNameRetrieveFileContent): qwenCompatibleRetrieveFileContentPath, @@ -707,6 +709,8 @@ func (m *qwenProvider) GetApiName(path string) ApiName { case strings.Contains(path, qwenTextEmbeddingPath), strings.Contains(path, qwenCompatibleTextEmbeddingPath): return ApiNameEmbeddings + case strings.Contains(path, qwenCompatibleResponsesPath): + return ApiNameResponses case strings.Contains(path, qwenAsyncAIGCPath): return ApiNameQwenAsyncAIGC case strings.Contains(path, qwenAsyncTaskPath): diff --git a/plugins/wasm-go/extensions/ai-proxy/test/api_paths.go b/plugins/wasm-go/extensions/ai-proxy/test/api_paths.go new file mode 100644 index 000000000..cce9e4439 --- /dev/null +++ b/plugins/wasm-go/extensions/ai-proxy/test/api_paths.go @@ -0,0 +1,116 @@ +package test + +import ( + "encoding/json" + "testing" + + "github.com/higress-group/proxy-wasm-go-sdk/proxywasm/types" + wasmtest "github.com/higress-group/wasm-go/pkg/test" + "github.com/stretchr/testify/require" +) + +func openAICustomEndpointConfig(customURL string) json.RawMessage { + data, _ := json.Marshal(map[string]interface{}{ + "provider": map[string]interface{}{ + "type": "openai", + "apiTokens": []string{"sk-openai-test-custom-endpoint"}, + "modelMapping": map[string]string{ + "*": "gpt-4o-mini", + }, + "openaiCustomUrl": customURL, + }, + }) + return data +} + +var openAICustomAudioTranscriptionsEndpointConfig = openAICustomEndpointConfig("https://custom.openai.com/v1/audio/transcriptions") +var openAICustomAudioTranslationsEndpointConfig = openAICustomEndpointConfig("https://custom.openai.com/v1/audio/translations") +var openAICustomRealtimeEndpointConfig = openAICustomEndpointConfig("https://custom.openai.com/v1/realtime") +var openAICustomRealtimeSessionsEndpointConfig = openAICustomEndpointConfig("https://custom.openai.com/v1/realtime/sessions") + +func RunApiPathRegressionTests(t *testing.T) { + wasmtest.RunTest(t, func(t *testing.T) { + t.Run("openai direct custom endpoint audio transcriptions", func(t *testing.T) { + host, status := wasmtest.NewTestHost(openAICustomAudioTranscriptionsEndpointConfig) + defer host.Reset() + require.Equal(t, types.OnPluginStartStatusOK, status) + + action := host.CallOnHttpRequestHeaders([][2]string{ + {":authority", "example.com"}, + {":path", "/v1/audio/transcriptions"}, + {":method", "POST"}, + {"Content-Type", "application/json"}, + }) + require.Equal(t, types.HeaderStopIteration, action) + + requestHeaders := host.GetRequestHeaders() + pathValue, hasPath := wasmtest.GetHeaderValue(requestHeaders, ":path") + require.True(t, hasPath) + require.Equal(t, "/v1/audio/transcriptions", pathValue) + }) + + t.Run("openai direct custom endpoint audio translations", func(t *testing.T) { + host, status := wasmtest.NewTestHost(openAICustomAudioTranslationsEndpointConfig) + defer host.Reset() + require.Equal(t, types.OnPluginStartStatusOK, status) + + action := host.CallOnHttpRequestHeaders([][2]string{ + {":authority", "example.com"}, + {":path", "/v1/audio/translations"}, + {":method", "POST"}, + {"Content-Type", "application/json"}, + }) + require.Equal(t, types.HeaderStopIteration, action) + + requestHeaders := host.GetRequestHeaders() + pathValue, hasPath := wasmtest.GetHeaderValue(requestHeaders, ":path") + require.True(t, hasPath) + require.Equal(t, "/v1/audio/translations", pathValue) + }) + + t.Run("openai direct custom endpoint realtime", func(t *testing.T) { + host, status := wasmtest.NewTestHost(openAICustomRealtimeEndpointConfig) + defer host.Reset() + require.Equal(t, types.OnPluginStartStatusOK, status) + + action := host.CallOnHttpRequestHeaders([][2]string{ + {":authority", "example.com"}, + {":path", "/v1/realtime"}, + {":method", "GET"}, + {"Connection", "Upgrade"}, + {"Upgrade", "websocket"}, + {"Sec-WebSocket-Version", "13"}, + {"Sec-WebSocket-Key", "dGhlIHNhbXBsZSBub25jZQ=="}, + }) + require.True(t, action == types.ActionContinue || action == types.HeaderStopIteration) + + requestHeaders := host.GetRequestHeaders() + pathValue, hasPath := wasmtest.GetHeaderValue(requestHeaders, ":path") + require.True(t, hasPath) + require.Equal(t, "/v1/realtime", pathValue) + }) + + t.Run("openai non-direct endpoint appends mapped realtime suffix", func(t *testing.T) { + host, status := wasmtest.NewTestHost(openAICustomRealtimeSessionsEndpointConfig) + defer host.Reset() + require.Equal(t, types.OnPluginStartStatusOK, status) + + action := host.CallOnHttpRequestHeaders([][2]string{ + {":authority", "example.com"}, + {":path", "/v1/realtime"}, + {":method", "GET"}, + {"Connection", "Upgrade"}, + {"Upgrade", "websocket"}, + {"Sec-WebSocket-Version", "13"}, + {"Sec-WebSocket-Key", "dGhlIHNhbXBsZSBub25jZQ=="}, + }) + require.True(t, action == types.ActionContinue || action == types.HeaderStopIteration) + + requestHeaders := host.GetRequestHeaders() + pathValue, hasPath := wasmtest.GetHeaderValue(requestHeaders, ":path") + require.True(t, hasPath) + require.Equal(t, "/v1/realtime/sessions/realtime", pathValue) + }) + + }) +} diff --git a/plugins/wasm-go/extensions/ai-proxy/test/openai.go b/plugins/wasm-go/extensions/ai-proxy/test/openai.go index 0bfb225e3..2f72fabb0 100644 --- a/plugins/wasm-go/extensions/ai-proxy/test/openai.go +++ b/plugins/wasm-go/extensions/ai-proxy/test/openai.go @@ -243,6 +243,84 @@ func RunOpenAIOnHttpRequestHeadersTests(t *testing.T) { require.Contains(t, authValue, "sk-openai-test123456789", "Authorization should contain OpenAI API token") }) + // 测试OpenAI请求头处理(语音转写接口) + t.Run("openai audio transcriptions request headers", func(t *testing.T) { + host, status := test.NewTestHost(basicOpenAIConfig) + defer host.Reset() + require.Equal(t, types.OnPluginStartStatusOK, status) + + action := host.CallOnHttpRequestHeaders([][2]string{ + {":authority", "example.com"}, + {":path", "/v1/audio/transcriptions"}, + {":method", "POST"}, + {"Content-Type", "application/json"}, + }) + + require.Equal(t, types.HeaderStopIteration, action) + + requestHeaders := host.GetRequestHeaders() + require.NotNil(t, requestHeaders) + + hostValue, hasHost := test.GetHeaderValue(requestHeaders, ":authority") + require.True(t, hasHost) + require.Equal(t, "api.openai.com", hostValue) + + pathValue, hasPath := test.GetHeaderValue(requestHeaders, ":path") + require.True(t, hasPath) + require.Contains(t, pathValue, "/v1/audio/transcriptions", "Path should contain audio transcriptions endpoint") + }) + + // 测试OpenAI请求头处理(语音翻译接口) + t.Run("openai audio translations request headers", func(t *testing.T) { + host, status := test.NewTestHost(basicOpenAIConfig) + defer host.Reset() + require.Equal(t, types.OnPluginStartStatusOK, status) + + action := host.CallOnHttpRequestHeaders([][2]string{ + {":authority", "example.com"}, + {":path", "/v1/audio/translations"}, + {":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, "/v1/audio/translations", "Path should contain audio translations endpoint") + }) + + // 测试OpenAI请求头处理(实时接口,WebSocket握手) + t.Run("openai realtime websocket handshake request headers", func(t *testing.T) { + host, status := test.NewTestHost(basicOpenAIConfig) + defer host.Reset() + require.Equal(t, types.OnPluginStartStatusOK, status) + + action := host.CallOnHttpRequestHeaders([][2]string{ + {":authority", "example.com"}, + {":path", "/v1/realtime?model=gpt-4o-realtime-preview"}, + {":method", "GET"}, + {"Connection", "Upgrade"}, + {"Upgrade", "websocket"}, + {"Sec-WebSocket-Version", "13"}, + {"Sec-WebSocket-Key", "dGhlIHNhbXBsZSBub25jZQ=="}, + }) + + // WebSocket 握手本身不应依赖请求体。受测试框架限制,某些场景可能仍返回 HeaderStopIteration。 + require.True(t, action == types.ActionContinue || action == types.HeaderStopIteration) + + requestHeaders := host.GetRequestHeaders() + require.NotNil(t, requestHeaders) + + pathValue, hasPath := test.GetHeaderValue(requestHeaders, ":path") + require.True(t, hasPath) + require.Contains(t, pathValue, "/v1/realtime", "Path should contain realtime endpoint") + require.Contains(t, pathValue, "model=gpt-4o-realtime-preview", "Query parameters should be preserved") + }) + // 测试OpenAI请求头处理(图像生成接口) t.Run("openai image generation request headers", func(t *testing.T) { host, status := test.NewTestHost(basicOpenAIConfig) @@ -305,6 +383,61 @@ func RunOpenAIOnHttpRequestHeadersTests(t *testing.T) { // 对于直接路径,应该保持原有路径 require.Contains(t, pathValue, "/v1/chat/completions", "Path should be preserved for direct custom path") }) + + // 测试OpenAI自定义域名请求头处理(间接路径语音转写) + t.Run("openai custom domain indirect path audio transcriptions request headers", func(t *testing.T) { + host, status := test.NewTestHost(openAICustomDomainIndirectPathConfig) + defer host.Reset() + require.Equal(t, types.OnPluginStartStatusOK, status) + + action := host.CallOnHttpRequestHeaders([][2]string{ + {":authority", "example.com"}, + {":path", "/v1/audio/transcriptions"}, + {":method", "POST"}, + {"Content-Type", "application/json"}, + }) + + require.Equal(t, types.HeaderStopIteration, action) + + requestHeaders := host.GetRequestHeaders() + require.NotNil(t, requestHeaders) + + hostValue, hasHost := test.GetHeaderValue(requestHeaders, ":authority") + require.True(t, hasHost) + require.Equal(t, "custom.openai.com", hostValue, "Host should be changed to custom domain") + + pathValue, hasPath := test.GetHeaderValue(requestHeaders, ":path") + require.True(t, hasPath) + require.Contains(t, pathValue, "/api/audio/transcriptions", "Path should be rewritten with indirect custom prefix") + }) + + // 测试OpenAI自定义域名请求头处理(间接路径 realtime,WebSocket握手) + t.Run("openai custom domain indirect path realtime websocket handshake request headers", func(t *testing.T) { + host, status := test.NewTestHost(openAICustomDomainIndirectPathConfig) + defer host.Reset() + require.Equal(t, types.OnPluginStartStatusOK, status) + + action := host.CallOnHttpRequestHeaders([][2]string{ + {":authority", "example.com"}, + {":path", "/v1/realtime?model=gpt-4o-realtime-preview"}, + {":method", "GET"}, + {"Connection", "Upgrade"}, + {"Upgrade", "websocket"}, + {"Sec-WebSocket-Version", "13"}, + {"Sec-WebSocket-Key", "dGhlIHNhbXBsZSBub25jZQ=="}, + }) + + // WebSocket 握手本身不应依赖请求体。受测试框架限制,某些场景可能仍返回 HeaderStopIteration。 + require.True(t, action == types.ActionContinue || action == types.HeaderStopIteration) + + requestHeaders := host.GetRequestHeaders() + require.NotNil(t, requestHeaders) + + pathValue, hasPath := test.GetHeaderValue(requestHeaders, ":path") + require.True(t, hasPath) + require.Contains(t, pathValue, "/api/realtime", "Path should be rewritten with indirect custom prefix") + require.Contains(t, pathValue, "model=gpt-4o-realtime-preview", "Query parameters should be preserved") + }) }) } diff --git a/plugins/wasm-go/extensions/ai-proxy/test/qwen.go b/plugins/wasm-go/extensions/ai-proxy/test/qwen.go index e693ed8c2..5a36a4dce 100644 --- a/plugins/wasm-go/extensions/ai-proxy/test/qwen.go +++ b/plugins/wasm-go/extensions/ai-proxy/test/qwen.go @@ -100,6 +100,22 @@ var qwenEnableCompatibleConfig = func() json.RawMessage { return data }() +// 测试配置:qwen original + 兼容模式(用于覆盖 provider.GetApiName 分支) +var qwenOriginalCompatibleConfig = func() json.RawMessage { + data, _ := json.Marshal(map[string]interface{}{ + "provider": map[string]interface{}{ + "type": "qwen", + "apiTokens": []string{"sk-qwen-original-compatible"}, + "modelMapping": map[string]string{ + "*": "qwen-turbo", + }, + "qwenEnableCompatible": true, + "protocol": "original", + }, + }) + return data +}() + // 测试配置:qwen文件ID配置 var qwenFileIdsConfig = func() json.RawMessage { data, _ := json.Marshal(map[string]interface{}{ @@ -159,6 +175,15 @@ var qwenConflictConfig = func() json.RawMessage { return data }() +func hasUnsupportedAPINameError(errorLogs []string) bool { + for _, log := range errorLogs { + if strings.Contains(log, "unsupported API name") { + return true + } + } + return false +} + func RunQwenParseConfigTests(t *testing.T) { test.RunGoTest(t, func(t *testing.T) { // 测试基本qwen配置解析 @@ -403,6 +428,29 @@ func RunQwenOnHttpRequestHeadersTests(t *testing.T) { require.True(t, hasPath) require.Contains(t, pathValue, "/compatible-mode/v1/chat/completions", "Path should use compatible mode path") }) + + // 测试qwen兼容模式请求头处理(responses接口) + t.Run("qwen compatible mode responses 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/responses"}, + {":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/v2/apps/protocols/compatible-mode/v1/responses", "Path should use compatible mode responses path") + }) }) } @@ -651,6 +699,112 @@ func RunQwenOnHttpRequestBodyTests(t *testing.T) { } require.True(t, hasCompatibleLogs, "Should have compatible mode processing logs") }) + + // 测试qwen请求体处理(兼容模式 responses接口) + t.Run("qwen compatible mode responses 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/responses"}, + {":method", "POST"}, + {"Content-Type", "application/json"}, + }) + + requestBody := `{"model":"qwen-turbo","input":"test"}` + 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", "Model name should be preserved in responses request") + }) + + // 测试qwen请求体处理(非兼容模式 responses接口应报不支持) + t.Run("qwen non-compatible mode responses request body unsupported", 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/responses"}, + {":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, "/v1/responses", "Path should remain unchanged when responses is unsupported") + + requestBody := `{"model":"qwen-turbo","input":"test"}` + bodyAction := host.CallOnHttpRequestBody([]byte(requestBody)) + require.Equal(t, types.ActionContinue, bodyAction) + + hasUnsupportedErr := hasUnsupportedAPINameError(host.GetErrorLogs()) + require.True(t, hasUnsupportedErr, "Should log unsupported API name for non-compatible responses") + }) + + // 覆盖 qwen.GetApiName 中以下分支: + // - qwenCompatibleTextEmbeddingPath => ApiNameEmbeddings + // - qwenCompatibleResponsesPath => ApiNameResponses + // - qwenAsyncAIGCPath => ApiNameQwenAsyncAIGC + // - qwenAsyncTaskPath => ApiNameQwenAsyncTask + t.Run("qwen original protocol get api name coverage for compatible embeddings responses and async paths", func(t *testing.T) { + cases := []struct { + name string + path string + }{ + { + name: "compatible embeddings path", + path: "/compatible-mode/v1/embeddings", + }, + { + name: "compatible responses path", + path: "/api/v2/apps/protocols/compatible-mode/v1/responses", + }, + { + name: "async aigc path", + path: "/api/v1/services/aigc/custom-async-endpoint", + }, + { + name: "async task path", + path: "/api/v1/tasks/task-123", + }, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + host, status := test.NewTestHost(qwenOriginalCompatibleConfig) + defer host.Reset() + require.Equal(t, types.OnPluginStartStatusOK, status) + + action := host.CallOnHttpRequestHeaders([][2]string{ + {":authority", "example.com"}, + {":path", tc.path}, + {":method", "POST"}, + {"Content-Type", "application/json"}, + }) + // 测试框架中 action 可能表现为 Continue 或 HeaderStopIteration, + // 这里关注的是后续 body 阶段不出现 unsupported API name。 + require.True(t, action == types.ActionContinue || action == types.HeaderStopIteration) + + requestBody := `{"model":"qwen-turbo","input":"test"}` + bodyAction := host.CallOnHttpRequestBody([]byte(requestBody)) + require.Equal(t, types.ActionContinue, bodyAction) + + hasUnsupportedErr := hasUnsupportedAPINameError(host.GetErrorLogs()) + require.False(t, hasUnsupportedErr, "Path should be recognized by qwen.GetApiName in original protocol") + }) + } + }) }) } @@ -986,6 +1140,51 @@ func RunQwenOnHttpResponseBodyTests(t *testing.T) { require.Contains(t, responseStr, "chat.completion", "Response should contain chat completion object") require.Contains(t, responseStr, "qwen-turbo", "Response should contain model name") }) + + // 测试qwen响应体处理(兼容模式 responses 接口透传) + t.Run("qwen compatible mode responses response 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/responses"}, + {":method", "POST"}, + {"Content-Type", "application/json"}, + }) + + requestBody := `{"model":"qwen-turbo","input":"test"}` + host.CallOnHttpRequestBody([]byte(requestBody)) + + responseHeaders := [][2]string{ + {":status", "200"}, + {"Content-Type", "application/json"}, + } + host.CallOnHttpResponseHeaders(responseHeaders) + + responseBody := `{ + "id": "resp-123", + "object": "response", + "status": "completed", + "output": [{ + "type": "message", + "role": "assistant", + "content": [{ + "type": "output_text", + "text": "hello" + }] + }] + }` + action := host.CallOnHttpResponseBody([]byte(responseBody)) + require.Equal(t, types.ActionContinue, action) + + processedResponseBody := host.GetResponseBody() + require.NotNil(t, processedResponseBody) + responseStr := string(processedResponseBody) + require.Contains(t, responseStr, "\"object\": \"response\"", "Responses API payload should be passthrough in compatible mode") + require.Contains(t, responseStr, "\"text\": \"hello\"", "Assistant content should be preserved") + }) }) }