From ae0bb418858b65914c596a28ee2dbe542a4765bc Mon Sep 17 00:00:00 2001 From: woody Date: Fri, 28 Nov 2025 17:44:22 +0800 Subject: [PATCH] Fix OpenAI capability rewrite dropping query string (#3168) --- .../wasm-go/extensions/ai-proxy/main_test.go | 8 ++ .../wasm-go/extensions/ai-proxy/test/util.go | 116 ++++++++++++++++++ .../wasm-go/extensions/ai-proxy/util/http.go | 32 ++++- 3 files changed, 153 insertions(+), 3 deletions(-) create mode 100644 plugins/wasm-go/extensions/ai-proxy/test/util.go diff --git a/plugins/wasm-go/extensions/ai-proxy/main_test.go b/plugins/wasm-go/extensions/ai-proxy/main_test.go index 2fb38fb68..97cb2b6ee 100644 --- a/plugins/wasm-go/extensions/ai-proxy/main_test.go +++ b/plugins/wasm-go/extensions/ai-proxy/main_test.go @@ -27,6 +27,10 @@ func Test_getApiName(t *testing.T) { {"openai files", "/v1/files", provider.ApiNameFiles}, {"openai retrieve file", "/v1/files/fileid", provider.ApiNameRetrieveFile}, {"openai retrieve file content", "/v1/files/fileid/content", provider.ApiNameRetrieveFileContent}, + {"openai videos", "/v1/videos", provider.ApiNameVideos}, + {"openai retrieve video", "/v1/videos/videoid", provider.ApiNameRetrieveVideo}, + {"openai retrieve video content", "/v1/videos/videoid/content", provider.ApiNameRetrieveVideoContent}, + {"openai video remix", "/v1/videos/videoid/remix", provider.ApiNameVideoRemix}, {"openai models", "/v1/models", provider.ApiNameModels}, {"openai fine tuning jobs", "/v1/fine_tuning/jobs", provider.ApiNameFineTuningJobs}, {"openai retrieve fine tuning job", "/v1/fine_tuning/jobs/jobid", provider.ApiNameRetrieveFineTuningJob}, @@ -109,3 +113,7 @@ func TestFireworks(t *testing.T) { test.RunFireworksOnHttpRequestHeadersTests(t) test.RunFireworksOnHttpRequestBodyTests(t) } + +func TestUtil(t *testing.T) { + test.RunMapRequestPathByCapabilityTests(t) +} diff --git a/plugins/wasm-go/extensions/ai-proxy/test/util.go b/plugins/wasm-go/extensions/ai-proxy/test/util.go new file mode 100644 index 000000000..9bd40990d --- /dev/null +++ b/plugins/wasm-go/extensions/ai-proxy/test/util.go @@ -0,0 +1,116 @@ +package test + +import ( + "testing" + + "github.com/alibaba/higress/plugins/wasm-go/extensions/ai-proxy/util" +) + +func RunMapRequestPathByCapabilityTests(t *testing.T) { + testCases := []struct { + name string + apiName string + origin string + mapping map[string]string + expected string + }{ + { + name: "no mapping returns empty", + apiName: "openai/v1/chatcompletions", + origin: "/v1/chat/completions", + mapping: map[string]string{}, + expected: "", + }, + { + name: "file placeholder is replaced", + apiName: "openai/v1/retrievefile", + origin: "/openai/v1/files/file-abc", + mapping: map[string]string{ + "openai/v1/retrievefile": "/v1/files/{file_id}", + }, + expected: "/v1/files/file-abc", + }, + { + name: "file content keeps query parameters", + apiName: "openai/v1/retrievefilecontent", + origin: "/openai/v1/files/file-123/content?variant=thumbnail", + mapping: map[string]string{ + "openai/v1/retrievefilecontent": "/v1/files/{file_id}/content", + }, + expected: "/v1/files/file-123/content?variant=thumbnail", + }, + { + name: "file content merges query string with mapped query", + apiName: "openai/v1/retrievefilecontent", + origin: "/openai/v1/files/file-123/content?variant=thumbnail", + mapping: map[string]string{ + "openai/v1/retrievefilecontent": "/v1/files/{file_id}/content?download=1", + }, + expected: "/v1/files/file-123/content?download=1&variant=thumbnail", + }, + { + name: "retrieve batch replaces batch id", + apiName: "openai/v1/retrievebatch", + origin: "/openai/v1/batches/batch-001", + mapping: map[string]string{ + "openai/v1/retrievebatch": "/v1/batches/{batch_id}", + }, + expected: "/v1/batches/batch-001", + }, + { + name: "cancel batch replaces batch id", + apiName: "openai/v1/cancelbatch", + origin: "/openai/v1/batches/batch-002/cancel", + mapping: map[string]string{ + "openai/v1/cancelbatch": "/v1/batches/{batch_id}/cancel", + }, + expected: "/v1/batches/batch-002/cancel", + }, + { + name: "video placeholder is replaced", + apiName: "openai/v1/retrievevideo", + origin: "/openai/v1/videos/video-xyz", + mapping: map[string]string{ + "openai/v1/retrievevideo": "/v1/videos/{video_id}", + }, + expected: "/v1/videos/video-xyz", + }, + { + name: "video content placeholder with query", + apiName: "openai/v1/retrievevideocontent", + origin: "/openai/v1/videos/video-xyz/content?variant=thumbnail", + mapping: map[string]string{ + "openai/v1/retrievevideocontent": "/v1/videos/{video_id}/content", + }, + expected: "/v1/videos/video-xyz/content?variant=thumbnail", + }, + { + name: "video remix placeholder is replaced", + apiName: "openai/v1/videoremix", + origin: "/openai/v1/videos/video-xyz/remix", + mapping: map[string]string{ + "openai/v1/videoremix": "/v1/videos/{video_id}/remix", + }, + expected: "/v1/videos/video-xyz/remix", + }, + { + name: "non placeholder mapping returns mapped path directly", + apiName: "openai/v1/videos", + origin: "/openai/v1/videos", + mapping: map[string]string{ + "openai/v1/videos": "/v1/videos", + }, + expected: "/v1/videos", + }, + } + + for _, tc := range testCases { + tc := tc + t.Run(tc.name, func(t *testing.T) { + got := util.MapRequestPathByCapability(tc.apiName, tc.origin, tc.mapping) + if got != tc.expected { + t.Fatalf("expected %q, got %q", tc.expected, got) + } + }) + } +} diff --git a/plugins/wasm-go/extensions/ai-proxy/util/http.go b/plugins/wasm-go/extensions/ai-proxy/util/http.go index 8fada21dd..b041104db 100644 --- a/plugins/wasm-go/extensions/ai-proxy/util/http.go +++ b/plugins/wasm-go/extensions/ai-proxy/util/http.go @@ -93,6 +93,19 @@ func MapRequestPathByCapability(apiName string, originPath string, mapping map[s if !exist { return "" } + mappedPathOnly := mappedPath + mappedQuery := "" + if queryIndex := strings.Index(mappedPathOnly, "?"); queryIndex >= 0 { + mappedPathOnly = mappedPathOnly[:queryIndex] + mappedQuery = mappedPath[queryIndex:] + } + // 将查询字符串从原始路径中剥离,避免干扰正则匹配 video_id 等占位符 + pathOnly := originPath + query := "" + if queryIndex := strings.Index(originPath, "?"); queryIndex >= 0 { + pathOnly = originPath[:queryIndex] + query = originPath[queryIndex:] + } if strings.Contains(mappedPath, "{") && strings.Contains(mappedPath, "}") { replacements := []struct { regx *regexp.Regexp @@ -108,8 +121,8 @@ func MapRequestPathByCapability(apiName string, originPath string, mapping map[s } for _, r := range replacements { - if r.regx.MatchString(originPath) { - subMatch := r.regx.FindStringSubmatch(originPath) + if r.regx.MatchString(pathOnly) { + subMatch := r.regx.FindStringSubmatch(pathOnly) if subMatch == nil { continue } @@ -118,12 +131,25 @@ func MapRequestPathByCapability(apiName string, originPath string, mapping map[s continue } id := subMatch[index] - mappedPath = r.regx.ReplaceAllStringFunc(mappedPath, func(s string) string { + mappedPathOnly = r.regx.ReplaceAllStringFunc(mappedPathOnly, func(s string) string { return strings.Replace(s, "{"+r.key+"}", id, 1) }) } } } + if mappedQuery != "" { + mappedPath = mappedPathOnly + mappedQuery + } else { + mappedPath = mappedPathOnly + } + if query != "" { + // 保留原始查询参数,例如 variant=thumbnail + if strings.Contains(mappedPath, "?") { + mappedPath = mappedPath + "&" + strings.TrimPrefix(query, "?") + } else { + mappedPath += query + } + } return mappedPath }