Feat/new api path support (#3620)

This commit is contained in:
woody
2026-03-19 19:25:55 +08:00
committed by GitHub
parent 62df71aadf
commit 045238944d
8 changed files with 481 additions and 0 deletions

View File

@@ -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},

View File

@@ -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)

View File

@@ -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")
}

View File

@@ -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),

View File

@@ -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):

View File

@@ -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)
})
})
}

View File

@@ -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自定义域名请求头处理间接路径 realtimeWebSocket握手
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")
})
})
}

View File

@@ -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")
})
})
}