Compare commits

..

1 Commits

Author SHA1 Message Date
johnlanni
b446651dd3 Add release notes 2026-02-22 12:31:15 +00:00
11 changed files with 61 additions and 863 deletions

View File

@@ -225,9 +225,9 @@ func onHttpRequestHeader(ctx wrapper.HttpContext, pluginConfig config.PluginConf
}
}
if contentType, _ := proxywasm.GetHttpRequestHeader(util.HeaderContentType); contentType != "" && !isSupportedRequestContentType(apiName, contentType) {
if contentType, _ := proxywasm.GetHttpRequestHeader(util.HeaderContentType); contentType != "" && !strings.Contains(contentType, util.MimeTypeApplicationJson) {
ctx.DontReadRequestBody()
log.Debugf("[onHttpRequestHeader] unsupported content type for api %s: %s, will not process the request body", apiName, contentType)
log.Debugf("[onHttpRequestHeader] unsupported content type: %s, will not process the request body", contentType)
}
if apiName == "" {
@@ -306,7 +306,6 @@ func onHttpRequestBody(ctx wrapper.HttpContext, pluginConfig config.PluginConfig
if err == nil {
return action
}
log.Errorf("[onHttpRequestBody] failed to process request body, apiName=%s, err=%v", apiName, err)
_ = util.ErrorHandler("ai-proxy.proc_req_body_failed", fmt.Errorf("failed to process request body: %v", err))
}
return types.ActionContinue
@@ -595,14 +594,3 @@ func getApiName(path string) provider.ApiName {
return ""
}
func isSupportedRequestContentType(apiName provider.ApiName, contentType string) bool {
if strings.Contains(contentType, util.MimeTypeApplicationJson) {
return true
}
contentType = strings.ToLower(contentType)
if strings.HasPrefix(contentType, "multipart/form-data") {
return apiName == provider.ApiNameImageEdit || apiName == provider.ApiNameImageVariation
}
return false
}

View File

@@ -63,54 +63,6 @@ func Test_getApiName(t *testing.T) {
}
}
func Test_isSupportedRequestContentType(t *testing.T) {
tests := []struct {
name string
apiName provider.ApiName
contentType string
want bool
}{
{
name: "json chat completion",
apiName: provider.ApiNameChatCompletion,
contentType: "application/json",
want: true,
},
{
name: "multipart image edit",
apiName: provider.ApiNameImageEdit,
contentType: "multipart/form-data; boundary=----boundary",
want: true,
},
{
name: "multipart image variation",
apiName: provider.ApiNameImageVariation,
contentType: "multipart/form-data; boundary=----boundary",
want: true,
},
{
name: "multipart chat completion",
apiName: provider.ApiNameChatCompletion,
contentType: "multipart/form-data; boundary=----boundary",
want: false,
},
{
name: "text plain image edit",
apiName: provider.ApiNameImageEdit,
contentType: "text/plain",
want: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := isSupportedRequestContentType(tt.apiName, tt.contentType)
if got != tt.want {
t.Errorf("isSupportedRequestContentType(%v, %q) = %v, want %v", tt.apiName, tt.contentType, got, tt.want)
}
})
}
}
func TestAi360(t *testing.T) {
test.RunAi360ParseConfigTests(t)
test.RunAi360OnHttpRequestHeadersTests(t)
@@ -185,8 +137,6 @@ func TestVertex(t *testing.T) {
test.RunVertexExpressModeOnStreamingResponseBodyTests(t)
test.RunVertexExpressModeImageGenerationRequestBodyTests(t)
test.RunVertexExpressModeImageGenerationResponseBodyTests(t)
test.RunVertexExpressModeImageEditVariationRequestBodyTests(t)
test.RunVertexExpressModeImageEditVariationResponseBodyTests(t)
// Vertex Raw 模式测试
test.RunVertexRawModeOnHttpRequestHeadersTests(t)
test.RunVertexRawModeOnHttpRequestBodyTests(t)

View File

@@ -1,7 +1,6 @@
package provider
import (
"encoding/json"
"fmt"
"strings"
@@ -462,122 +461,6 @@ type imageGenerationRequest struct {
Size string `json:"size,omitempty"`
}
type imageInputURL struct {
URL string `json:"url,omitempty"`
ImageURL *chatMessageContentImageUrl `json:"image_url,omitempty"`
}
func (i *imageInputURL) UnmarshalJSON(data []byte) error {
// Support a plain string payload, e.g. "data:image/png;base64,..."
var rawURL string
if err := json.Unmarshal(data, &rawURL); err == nil {
i.URL = rawURL
i.ImageURL = nil
return nil
}
type alias imageInputURL
var value alias
if err := json.Unmarshal(data, &value); err != nil {
return err
}
*i = imageInputURL(value)
return nil
}
func (i *imageInputURL) GetURL() string {
if i == nil {
return ""
}
if i.ImageURL != nil && i.ImageURL.Url != "" {
return i.ImageURL.Url
}
return i.URL
}
type imageEditRequest struct {
Model string `json:"model"`
Prompt string `json:"prompt"`
Image *imageInputURL `json:"image,omitempty"`
Images []imageInputURL `json:"images,omitempty"`
ImageURL *imageInputURL `json:"image_url,omitempty"`
Mask *imageInputURL `json:"mask,omitempty"`
MaskURL *imageInputURL `json:"mask_url,omitempty"`
Background string `json:"background,omitempty"`
Moderation string `json:"moderation,omitempty"`
OutputCompression int `json:"output_compression,omitempty"`
OutputFormat string `json:"output_format,omitempty"`
Quality string `json:"quality,omitempty"`
ResponseFormat string `json:"response_format,omitempty"`
Style string `json:"style,omitempty"`
N int `json:"n,omitempty"`
Size string `json:"size,omitempty"`
}
func (r *imageEditRequest) GetImageURLs() []string {
urls := make([]string, 0, len(r.Images)+2)
for _, image := range r.Images {
if url := image.GetURL(); url != "" {
urls = append(urls, url)
}
}
if r.Image != nil {
if url := r.Image.GetURL(); url != "" {
urls = append(urls, url)
}
}
if r.ImageURL != nil {
if url := r.ImageURL.GetURL(); url != "" {
urls = append(urls, url)
}
}
return urls
}
func (r *imageEditRequest) HasMask() bool {
if r.Mask != nil && r.Mask.GetURL() != "" {
return true
}
return r.MaskURL != nil && r.MaskURL.GetURL() != ""
}
type imageVariationRequest struct {
Model string `json:"model"`
Prompt string `json:"prompt,omitempty"`
Image *imageInputURL `json:"image,omitempty"`
Images []imageInputURL `json:"images,omitempty"`
ImageURL *imageInputURL `json:"image_url,omitempty"`
Background string `json:"background,omitempty"`
Moderation string `json:"moderation,omitempty"`
OutputCompression int `json:"output_compression,omitempty"`
OutputFormat string `json:"output_format,omitempty"`
Quality string `json:"quality,omitempty"`
ResponseFormat string `json:"response_format,omitempty"`
Style string `json:"style,omitempty"`
N int `json:"n,omitempty"`
Size string `json:"size,omitempty"`
}
func (r *imageVariationRequest) GetImageURLs() []string {
urls := make([]string, 0, len(r.Images)+2)
for _, image := range r.Images {
if url := image.GetURL(); url != "" {
urls = append(urls, url)
}
}
if r.Image != nil {
if url := r.Image.GetURL(); url != "" {
urls = append(urls, url)
}
}
if r.ImageURL != nil {
if url := r.ImageURL.GetURL(); url != "" {
urls = append(urls, url)
}
}
return urls
}
type imageGenerationData struct {
URL string `json:"url,omitempty"`
B64 string `json:"b64_json,omitempty"`

View File

@@ -1,156 +0,0 @@
package provider
import (
"bytes"
"encoding/base64"
"fmt"
"io"
"mime"
"mime/multipart"
"net/http"
"strconv"
"strings"
)
type multipartImageRequest struct {
Model string
Prompt string
Size string
OutputFormat string
N int
ImageURLs []string
HasMask bool
}
func isMultipartFormData(contentType string) bool {
mediaType, _, err := mime.ParseMediaType(contentType)
if err != nil {
return false
}
return strings.EqualFold(mediaType, "multipart/form-data")
}
func parseMultipartImageRequest(body []byte, contentType string) (*multipartImageRequest, error) {
_, params, err := mime.ParseMediaType(contentType)
if err != nil {
return nil, fmt.Errorf("unable to parse content-type: %v", err)
}
boundary := params["boundary"]
if boundary == "" {
return nil, fmt.Errorf("missing multipart boundary")
}
req := &multipartImageRequest{
ImageURLs: make([]string, 0),
}
reader := multipart.NewReader(bytes.NewReader(body), boundary)
for {
part, err := reader.NextPart()
if err == io.EOF {
break
}
if err != nil {
return nil, fmt.Errorf("unable to read multipart part: %v", err)
}
fieldName := part.FormName()
if fieldName == "" {
_ = part.Close()
continue
}
partContentType := strings.TrimSpace(part.Header.Get("Content-Type"))
partData, err := io.ReadAll(part)
_ = part.Close()
if err != nil {
return nil, fmt.Errorf("unable to read multipart field %s: %v", fieldName, err)
}
value := strings.TrimSpace(string(partData))
switch fieldName {
case "model":
req.Model = value
continue
case "prompt":
req.Prompt = value
continue
case "size":
req.Size = value
continue
case "output_format":
req.OutputFormat = value
continue
case "n":
if value != "" {
if parsed, err := strconv.Atoi(value); err == nil {
req.N = parsed
}
}
continue
}
if isMultipartImageField(fieldName) {
if isMultipartImageURLValue(value) {
req.ImageURLs = append(req.ImageURLs, value)
continue
}
if len(partData) == 0 {
continue
}
imageURL := buildMultipartDataURL(partContentType, partData)
req.ImageURLs = append(req.ImageURLs, imageURL)
continue
}
if isMultipartMaskField(fieldName) {
if len(partData) > 0 || value != "" {
req.HasMask = true
}
continue
}
}
return req, nil
}
func isMultipartImageField(fieldName string) bool {
return fieldName == "image" || fieldName == "image[]" || strings.HasPrefix(fieldName, "image[")
}
func isMultipartMaskField(fieldName string) bool {
return fieldName == "mask" || fieldName == "mask[]" || strings.HasPrefix(fieldName, "mask[")
}
func isMultipartImageURLValue(value string) bool {
if value == "" {
return false
}
loweredValue := strings.ToLower(value)
return strings.HasPrefix(loweredValue, "data:") || strings.HasPrefix(loweredValue, "http://") || strings.HasPrefix(loweredValue, "https://")
}
func buildMultipartDataURL(contentType string, data []byte) string {
mimeType := strings.TrimSpace(contentType)
if mimeType == "" || strings.EqualFold(mimeType, "application/octet-stream") {
mimeType = http.DetectContentType(data)
}
mimeType = normalizeMultipartMimeType(mimeType)
if mimeType == "" {
mimeType = "application/octet-stream"
}
encoded := base64.StdEncoding.EncodeToString(data)
return fmt.Sprintf("data:%s;base64,%s", mimeType, encoded)
}
func normalizeMultipartMimeType(contentType string) string {
contentType = strings.TrimSpace(contentType)
if contentType == "" {
return ""
}
mediaType, _, err := mime.ParseMediaType(contentType)
if err == nil && mediaType != "" {
return strings.TrimSpace(mediaType)
}
if idx := strings.Index(contentType, ";"); idx > 0 {
return strings.TrimSpace(contentType[:idx])
}
return contentType
}

View File

@@ -763,19 +763,19 @@ func (c *ProviderConfig) GetRandomToken() string {
func isStatefulAPI(apiName string) bool {
// These APIs maintain session state and should be routed to the same provider consistently
statefulAPIs := map[string]bool{
string(ApiNameResponses): true, // Response API - uses previous_response_id
string(ApiNameFiles): true, // Files API - maintains file state
string(ApiNameRetrieveFile): true, // File retrieval - depends on file upload
string(ApiNameRetrieveFileContent): true, // File content - depends on file upload
string(ApiNameBatches): true, // Batch API - maintains batch state
string(ApiNameRetrieveBatch): true, // Batch status - depends on batch creation
string(ApiNameCancelBatch): true, // Batch operations - depends on batch state
string(ApiNameFineTuningJobs): true, // Fine-tuning - maintains job state
string(ApiNameRetrieveFineTuningJob): true, // Fine-tuning job status
string(ApiNameFineTuningJobEvents): true, // Fine-tuning events
string(ApiNameFineTuningJobCheckpoints): true, // Fine-tuning checkpoints
string(ApiNameCancelFineTuningJob): true, // Cancel fine-tuning job
string(ApiNameResumeFineTuningJob): true, // Resume fine-tuning job
string(ApiNameResponses): true, // Response API - uses previous_response_id
string(ApiNameFiles): true, // Files API - maintains file state
string(ApiNameRetrieveFile): true, // File retrieval - depends on file upload
string(ApiNameRetrieveFileContent): true, // File content - depends on file upload
string(ApiNameBatches): true, // Batch API - maintains batch state
string(ApiNameRetrieveBatch): true, // Batch status - depends on batch creation
string(ApiNameCancelBatch): true, // Batch operations - depends on batch state
string(ApiNameFineTuningJobs): true, // Fine-tuning - maintains job state
string(ApiNameRetrieveFineTuningJob): true, // Fine-tuning job status
string(ApiNameFineTuningJobEvents): true, // Fine-tuning events
string(ApiNameFineTuningJobCheckpoints): true, // Fine-tuning checkpoints
string(ApiNameCancelFineTuningJob): true, // Cancel fine-tuning job
string(ApiNameResumeFineTuningJob): true, // Resume fine-tuning job
}
return statefulAPIs[apiName]
}
@@ -845,16 +845,6 @@ func (c *ProviderConfig) parseRequestAndMapModel(ctx wrapper.HttpContext, reques
return err
}
return c.setRequestModel(ctx, req)
case *imageEditRequest:
if err := decodeImageEditRequest(body, req); err != nil {
return err
}
return c.setRequestModel(ctx, req)
case *imageVariationRequest:
if err := decodeImageVariationRequest(body, req); err != nil {
return err
}
return c.setRequestModel(ctx, req)
default:
return errors.New("unsupported request type")
}
@@ -870,10 +860,6 @@ func (c *ProviderConfig) setRequestModel(ctx wrapper.HttpContext, request interf
model = &req.Model
case *imageGenerationRequest:
model = &req.Model
case *imageEditRequest:
model = &req.Model
case *imageVariationRequest:
model = &req.Model
default:
return errors.New("unsupported request type")
}

View File

@@ -37,7 +37,7 @@ const (
qwenCompatibleRetrieveBatchPath = "/compatible-mode/v1/batches/{batch_id}"
qwenBailianPath = "/api/v1/apps"
qwenMultimodalGenerationPath = "/api/v1/services/aigc/multimodal-generation/generation"
qwenAnthropicMessagesPath = "/apps/anthropic/v1/messages"
qwenAnthropicMessagesPath = "/api/v2/apps/claude-code-proxy/v1/messages"
qwenAsyncAIGCPath = "/api/v1/services/aigc/"
qwenAsyncTaskPath = "/api/v1/tasks/"

View File

@@ -4,8 +4,8 @@ import (
"encoding/json"
"fmt"
"github.com/higress-group/proxy-wasm-go-sdk/proxywasm"
"github.com/higress-group/wasm-go/pkg/log"
"github.com/higress-group/proxy-wasm-go-sdk/proxywasm"
)
func decodeChatCompletionRequest(body []byte, request *chatCompletionRequest) error {
@@ -32,20 +32,6 @@ func decodeImageGenerationRequest(body []byte, request *imageGenerationRequest)
return nil
}
func decodeImageEditRequest(body []byte, request *imageEditRequest) error {
if err := json.Unmarshal(body, request); err != nil {
return fmt.Errorf("unable to unmarshal request: %v", err)
}
return nil
}
func decodeImageVariationRequest(body []byte, request *imageVariationRequest) error {
if err := json.Unmarshal(body, request); err != nil {
return fmt.Errorf("unable to unmarshal request: %v", err)
}
return nil
}
func replaceJsonRequestBody(request interface{}) error {
body, err := json.Marshal(request)
if err != nil {

View File

@@ -46,7 +46,6 @@ const (
contextOpenAICompatibleMarker = "isOpenAICompatibleRequest"
contextVertexRawMarker = "isVertexRawRequest"
vertexAnthropicVersion = "vertex-2023-10-16"
vertexImageVariationDefaultPrompt = "Create variations of the provided image."
)
// vertexRawPathRegex 匹配原生 Vertex AI REST API 路径
@@ -99,8 +98,6 @@ func (v *vertexProviderInitializer) DefaultCapabilities() map[string]string {
string(ApiNameChatCompletion): vertexPathTemplate,
string(ApiNameEmbeddings): vertexPathTemplate,
string(ApiNameImageGeneration): vertexPathTemplate,
string(ApiNameImageEdit): vertexPathTemplate,
string(ApiNameImageVariation): vertexPathTemplate,
string(ApiNameVertexRaw): "", // 空字符串表示保持原路径,不做路径转换
}
}
@@ -310,10 +307,6 @@ func (v *vertexProvider) TransformRequestBodyHeaders(ctx wrapper.HttpContext, ap
return v.onEmbeddingsRequestBody(ctx, body, headers)
case ApiNameImageGeneration:
return v.onImageGenerationRequestBody(ctx, body, headers)
case ApiNameImageEdit:
return v.onImageEditRequestBody(ctx, body, headers)
case ApiNameImageVariation:
return v.onImageVariationRequestBody(ctx, body, headers)
default:
return body, nil
}
@@ -394,108 +387,11 @@ func (v *vertexProvider) onImageGenerationRequestBody(ctx wrapper.HttpContext, b
path := v.getRequestPath(ApiNameImageGeneration, request.Model, false)
util.OverwriteRequestPathHeader(headers, path)
vertexRequest, err := v.buildVertexImageGenerationRequest(request)
if err != nil {
return nil, err
}
vertexRequest := v.buildVertexImageGenerationRequest(request)
return json.Marshal(vertexRequest)
}
func (v *vertexProvider) onImageEditRequestBody(ctx wrapper.HttpContext, body []byte, headers http.Header) ([]byte, error) {
request := &imageEditRequest{}
imageURLs := make([]string, 0)
contentType := headers.Get("Content-Type")
if isMultipartFormData(contentType) {
parsedRequest, err := parseMultipartImageRequest(body, contentType)
if err != nil {
return nil, err
}
request.Model = parsedRequest.Model
request.Prompt = parsedRequest.Prompt
request.Size = parsedRequest.Size
request.OutputFormat = parsedRequest.OutputFormat
request.N = parsedRequest.N
imageURLs = parsedRequest.ImageURLs
if err := v.config.mapModel(ctx, &request.Model); err != nil {
return nil, err
}
if parsedRequest.HasMask {
return nil, fmt.Errorf("mask is not supported for vertex image edits yet")
}
} else {
if err := v.config.parseRequestAndMapModel(ctx, request, body); err != nil {
return nil, err
}
if request.HasMask() {
return nil, fmt.Errorf("mask is not supported for vertex image edits yet")
}
imageURLs = request.GetImageURLs()
}
if len(imageURLs) == 0 {
return nil, fmt.Errorf("missing image_url in request")
}
if request.Prompt == "" {
return nil, fmt.Errorf("missing prompt in request")
}
path := v.getRequestPath(ApiNameImageEdit, request.Model, false)
util.OverwriteRequestPathHeader(headers, path)
headers.Set("Content-Type", util.MimeTypeApplicationJson)
vertexRequest, err := v.buildVertexImageRequest(request.Prompt, request.Size, request.OutputFormat, imageURLs)
if err != nil {
return nil, err
}
return json.Marshal(vertexRequest)
}
func (v *vertexProvider) onImageVariationRequestBody(ctx wrapper.HttpContext, body []byte, headers http.Header) ([]byte, error) {
request := &imageVariationRequest{}
imageURLs := make([]string, 0)
contentType := headers.Get("Content-Type")
if isMultipartFormData(contentType) {
parsedRequest, err := parseMultipartImageRequest(body, contentType)
if err != nil {
return nil, err
}
request.Model = parsedRequest.Model
request.Prompt = parsedRequest.Prompt
request.Size = parsedRequest.Size
request.OutputFormat = parsedRequest.OutputFormat
request.N = parsedRequest.N
imageURLs = parsedRequest.ImageURLs
if err := v.config.mapModel(ctx, &request.Model); err != nil {
return nil, err
}
} else {
if err := v.config.parseRequestAndMapModel(ctx, request, body); err != nil {
return nil, err
}
imageURLs = request.GetImageURLs()
}
if len(imageURLs) == 0 {
return nil, fmt.Errorf("missing image_url in request")
}
prompt := request.Prompt
if prompt == "" {
prompt = vertexImageVariationDefaultPrompt
}
path := v.getRequestPath(ApiNameImageVariation, request.Model, false)
util.OverwriteRequestPathHeader(headers, path)
headers.Set("Content-Type", util.MimeTypeApplicationJson)
vertexRequest, err := v.buildVertexImageRequest(prompt, request.Size, request.OutputFormat, imageURLs)
if err != nil {
return nil, err
}
return json.Marshal(vertexRequest)
}
func (v *vertexProvider) buildVertexImageGenerationRequest(request *imageGenerationRequest) (*vertexChatRequest, error) {
return v.buildVertexImageRequest(request.Prompt, request.Size, request.OutputFormat, nil)
}
func (v *vertexProvider) buildVertexImageRequest(prompt string, size string, outputFormat string, imageURLs []string) (*vertexChatRequest, error) {
func (v *vertexProvider) buildVertexImageGenerationRequest(request *imageGenerationRequest) *vertexChatRequest {
// 构建安全设置
safetySettings := make([]vertexChatSafetySetting, 0)
for category, threshold := range v.config.geminiSafetySetting {
@@ -506,12 +402,12 @@ func (v *vertexProvider) buildVertexImageRequest(prompt string, size string, out
}
// 解析尺寸参数
aspectRatio, imageSize := v.parseImageSize(size)
aspectRatio, imageSize := v.parseImageSize(request.Size)
// 确定输出 MIME 类型
mimeType := "image/png"
if outputFormat != "" {
switch outputFormat {
if request.OutputFormat != "" {
switch request.OutputFormat {
case "jpeg", "jpg":
mimeType = "image/jpeg"
case "webp":
@@ -521,27 +417,12 @@ func (v *vertexProvider) buildVertexImageRequest(prompt string, size string, out
}
}
parts := make([]vertexPart, 0, len(imageURLs)+1)
for _, imageURL := range imageURLs {
part, err := convertMediaContent(imageURL)
if err != nil {
return nil, err
}
parts = append(parts, part)
}
if prompt != "" {
parts = append(parts, vertexPart{
Text: prompt,
})
}
if len(parts) == 0 {
return nil, fmt.Errorf("missing prompt and image_url in request")
}
vertexRequest := &vertexChatRequest{
Contents: []vertexChatContent{{
Role: roleUser,
Parts: parts,
Role: roleUser,
Parts: []vertexPart{{
Text: request.Prompt,
}},
}},
SafetySettings: safetySettings,
GenerationConfig: vertexChatGenerationConfig{
@@ -559,7 +440,7 @@ func (v *vertexProvider) buildVertexImageRequest(prompt string, size string, out
},
}
return vertexRequest, nil
return vertexRequest
}
// parseImageSize 解析 OpenAI 格式的尺寸字符串(如 "1024x1024")为 Vertex AI 的 aspectRatio 和 imageSize
@@ -672,7 +553,7 @@ func (v *vertexProvider) TransformResponseBody(ctx wrapper.HttpContext, apiName
return v.onChatCompletionResponseBody(ctx, body)
case ApiNameEmbeddings:
return v.onEmbeddingsResponseBody(ctx, body)
case ApiNameImageGeneration, ApiNameImageEdit, ApiNameImageVariation:
case ApiNameImageGeneration:
return v.onImageGenerationResponseBody(ctx, body)
default:
return body, nil
@@ -903,7 +784,7 @@ func (v *vertexProvider) getRequestPath(apiName ApiName, modelId string, stream
switch apiName {
case ApiNameEmbeddings:
action = vertexEmbeddingAction
case ApiNameImageGeneration, ApiNameImageEdit, ApiNameImageVariation:
case ApiNameImageGeneration:
// 图片生成使用非流式端点,需要完整响应
action = vertexChatCompletionAction
default:

View File

@@ -1,9 +1,7 @@
package test
import (
"bytes"
"encoding/json"
"mime/multipart"
"strings"
"testing"
@@ -1275,324 +1273,6 @@ func RunVertexExpressModeImageGenerationResponseBodyTests(t *testing.T) {
})
}
func buildMultipartRequestBody(t *testing.T, fields map[string]string, files map[string][]byte) ([]byte, string) {
var buffer bytes.Buffer
writer := multipart.NewWriter(&buffer)
for key, value := range fields {
require.NoError(t, writer.WriteField(key, value))
}
for fieldName, data := range files {
part, err := writer.CreateFormFile(fieldName, "upload-image.png")
require.NoError(t, err)
_, err = part.Write(data)
require.NoError(t, err)
}
require.NoError(t, writer.Close())
return buffer.Bytes(), writer.FormDataContentType()
}
func RunVertexExpressModeImageEditVariationRequestBodyTests(t *testing.T) {
test.RunTest(t, func(t *testing.T) {
const testDataURL = "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg=="
t.Run("vertex express mode image edit request body with image_url", func(t *testing.T) {
host, status := test.NewTestHost(vertexExpressModeConfig)
defer host.Reset()
require.Equal(t, types.OnPluginStartStatusOK, status)
host.CallOnHttpRequestHeaders([][2]string{
{":authority", "example.com"},
{":path", "/v1/images/edits"},
{":method", "POST"},
{"Content-Type", "application/json"},
})
requestBody := `{"model":"gemini-2.0-flash-exp","prompt":"Add sunglasses to the cat","image":{"image_url":{"url":"` + testDataURL + `"}},"size":"1024x1024"}`
action := host.CallOnHttpRequestBody([]byte(requestBody))
require.Equal(t, types.ActionContinue, action)
processedBody := host.GetRequestBody()
require.NotNil(t, processedBody)
bodyStr := string(processedBody)
require.Contains(t, bodyStr, "inlineData", "Request should contain inlineData converted from image_url")
require.Contains(t, bodyStr, "Add sunglasses to the cat", "Prompt text should be preserved")
require.NotContains(t, bodyStr, "image_url", "OpenAI image_url field should be converted to Vertex format")
requestHeaders := host.GetRequestHeaders()
pathHeader := ""
for _, header := range requestHeaders {
if header[0] == ":path" {
pathHeader = header[1]
break
}
}
require.Contains(t, pathHeader, "generateContent", "Image edit should use generateContent action")
require.Contains(t, pathHeader, "key=test-api-key-123456789", "Path should contain API key")
})
t.Run("vertex express mode image edit request body with image string", func(t *testing.T) {
host, status := test.NewTestHost(vertexExpressModeConfig)
defer host.Reset()
require.Equal(t, types.OnPluginStartStatusOK, status)
host.CallOnHttpRequestHeaders([][2]string{
{":authority", "example.com"},
{":path", "/v1/images/edits"},
{":method", "POST"},
{"Content-Type", "application/json"},
})
requestBody := `{"model":"gemini-2.0-flash-exp","prompt":"Add sunglasses to the cat","image":"` + testDataURL + `","size":"1024x1024"}`
action := host.CallOnHttpRequestBody([]byte(requestBody))
require.Equal(t, types.ActionContinue, action)
processedBody := host.GetRequestBody()
require.NotNil(t, processedBody)
bodyStr := string(processedBody)
require.Contains(t, bodyStr, "inlineData", "Request should contain inlineData converted from image string")
require.Contains(t, bodyStr, "Add sunglasses to the cat", "Prompt text should be preserved")
})
t.Run("vertex express mode image edit multipart request body", func(t *testing.T) {
host, status := test.NewTestHost(vertexExpressModeConfig)
defer host.Reset()
require.Equal(t, types.OnPluginStartStatusOK, status)
body, contentType := buildMultipartRequestBody(t, map[string]string{
"model": "gemini-2.0-flash-exp",
"prompt": "Add sunglasses to the cat",
"size": "1024x1024",
}, map[string][]byte{
"image": []byte("fake-image-content"),
})
host.CallOnHttpRequestHeaders([][2]string{
{":authority", "example.com"},
{":path", "/v1/images/edits"},
{":method", "POST"},
{"Content-Type", contentType},
})
action := host.CallOnHttpRequestBody(body)
require.Equal(t, types.ActionContinue, action)
processedBody := host.GetRequestBody()
require.NotNil(t, processedBody)
bodyStr := string(processedBody)
require.Contains(t, bodyStr, "inlineData", "Multipart image should be converted to inlineData")
require.Contains(t, bodyStr, "Add sunglasses to the cat", "Prompt text should be preserved")
requestHeaders := host.GetRequestHeaders()
require.True(t, test.HasHeaderWithValue(requestHeaders, "Content-Type", "application/json"), "Content-Type should be rewritten to application/json")
})
t.Run("vertex express mode image variation multipart request body", func(t *testing.T) {
host, status := test.NewTestHost(vertexExpressModeConfig)
defer host.Reset()
require.Equal(t, types.OnPluginStartStatusOK, status)
body, contentType := buildMultipartRequestBody(t, map[string]string{
"model": "gemini-2.0-flash-exp",
"size": "1024x1024",
}, map[string][]byte{
"image": []byte("fake-image-content"),
})
host.CallOnHttpRequestHeaders([][2]string{
{":authority", "example.com"},
{":path", "/v1/images/variations"},
{":method", "POST"},
{"Content-Type", contentType},
})
action := host.CallOnHttpRequestBody(body)
require.Equal(t, types.ActionContinue, action)
processedBody := host.GetRequestBody()
require.NotNil(t, processedBody)
bodyStr := string(processedBody)
require.Contains(t, bodyStr, "inlineData", "Multipart image should be converted to inlineData")
require.Contains(t, bodyStr, "Create variations of the provided image.", "Variation request should inject a default prompt")
requestHeaders := host.GetRequestHeaders()
require.True(t, test.HasHeaderWithValue(requestHeaders, "Content-Type", "application/json"), "Content-Type should be rewritten to application/json")
})
t.Run("vertex express mode image edit with model mapping", func(t *testing.T) {
host, status := test.NewTestHost(vertexExpressModeWithModelMappingConfig)
defer host.Reset()
require.Equal(t, types.OnPluginStartStatusOK, status)
host.CallOnHttpRequestHeaders([][2]string{
{":authority", "example.com"},
{":path", "/v1/images/edits"},
{":method", "POST"},
{"Content-Type", "application/json"},
})
requestBody := `{"model":"gpt-4","prompt":"Turn it into watercolor","image_url":{"url":"` + testDataURL + `"}}`
action := host.CallOnHttpRequestBody([]byte(requestBody))
require.Equal(t, types.ActionContinue, action)
requestHeaders := host.GetRequestHeaders()
pathHeader := ""
for _, header := range requestHeaders {
if header[0] == ":path" {
pathHeader = header[1]
break
}
}
require.Contains(t, pathHeader, "gemini-2.5-flash", "Path should contain mapped model name")
})
t.Run("vertex express mode image variation request body with image_url", func(t *testing.T) {
host, status := test.NewTestHost(vertexExpressModeConfig)
defer host.Reset()
require.Equal(t, types.OnPluginStartStatusOK, status)
host.CallOnHttpRequestHeaders([][2]string{
{":authority", "example.com"},
{":path", "/v1/images/variations"},
{":method", "POST"},
{"Content-Type", "application/json"},
})
requestBody := `{"model":"gemini-2.0-flash-exp","image_url":{"url":"` + testDataURL + `"}}`
action := host.CallOnHttpRequestBody([]byte(requestBody))
require.Equal(t, types.ActionContinue, action)
processedBody := host.GetRequestBody()
require.NotNil(t, processedBody)
bodyStr := string(processedBody)
require.Contains(t, bodyStr, "inlineData", "Request should contain inlineData converted from image_url")
require.Contains(t, bodyStr, "Create variations of the provided image.", "Variation request should inject a default prompt")
requestHeaders := host.GetRequestHeaders()
pathHeader := ""
for _, header := range requestHeaders {
if header[0] == ":path" {
pathHeader = header[1]
break
}
}
require.Contains(t, pathHeader, "generateContent", "Image variation should use generateContent action")
require.Contains(t, pathHeader, "key=test-api-key-123456789", "Path should contain API key")
})
})
}
func RunVertexExpressModeImageEditVariationResponseBodyTests(t *testing.T) {
test.RunTest(t, func(t *testing.T) {
const testDataURL = "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg=="
t.Run("vertex express mode image edit response body", func(t *testing.T) {
host, status := test.NewTestHost(vertexExpressModeConfig)
defer host.Reset()
require.Equal(t, types.OnPluginStartStatusOK, status)
host.CallOnHttpRequestHeaders([][2]string{
{":authority", "example.com"},
{":path", "/v1/images/edits"},
{":method", "POST"},
{"Content-Type", "application/json"},
})
requestBody := `{"model":"gemini-2.0-flash-exp","prompt":"Add glasses","image_url":{"url":"` + testDataURL + `"}}`
host.CallOnHttpRequestBody([]byte(requestBody))
host.SetProperty([]string{"response", "code_details"}, []byte("via_upstream"))
host.CallOnHttpResponseHeaders([][2]string{
{":status", "200"},
{"Content-Type", "application/json"},
})
responseBody := `{
"candidates": [{
"content": {
"role": "model",
"parts": [{
"inlineData": {
"mimeType": "image/png",
"data": "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg=="
}
}]
}
}],
"usageMetadata": {
"promptTokenCount": 12,
"candidatesTokenCount": 1024,
"totalTokenCount": 1036
}
}`
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, "b64_json", "Response should contain b64_json field")
require.Contains(t, responseStr, "usage", "Response should contain usage field")
})
t.Run("vertex express mode image variation response body", func(t *testing.T) {
host, status := test.NewTestHost(vertexExpressModeConfig)
defer host.Reset()
require.Equal(t, types.OnPluginStartStatusOK, status)
host.CallOnHttpRequestHeaders([][2]string{
{":authority", "example.com"},
{":path", "/v1/images/variations"},
{":method", "POST"},
{"Content-Type", "application/json"},
})
requestBody := `{"model":"gemini-2.0-flash-exp","image_url":{"url":"` + testDataURL + `"}}`
host.CallOnHttpRequestBody([]byte(requestBody))
host.SetProperty([]string{"response", "code_details"}, []byte("via_upstream"))
host.CallOnHttpResponseHeaders([][2]string{
{":status", "200"},
{"Content-Type", "application/json"},
})
responseBody := `{
"candidates": [{
"content": {
"role": "model",
"parts": [{
"inlineData": {
"mimeType": "image/png",
"data": "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg=="
}
}]
}
}],
"usageMetadata": {
"promptTokenCount": 8,
"candidatesTokenCount": 768,
"totalTokenCount": 776
}
}`
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, "b64_json", "Response should contain b64_json field")
require.Contains(t, responseStr, "usage", "Response should contain usage field")
})
})
}
// ==================== Vertex Raw 模式测试 ====================
func RunVertexRawModeOnHttpRequestHeadersTests(t *testing.T) {

View File

@@ -7,55 +7,55 @@ This release includes **6** updates, covering feature enhancements, bug fixes, a
### Distribution of Updates
- **New Features**: 4
- **Bug Fixes**: 2
- **New Features**: 4
- **Bug Fixes**: 2
---
## 📝 Full Changelog
## 📝 Full Change Log
### 🚀 New Features (Features)
### 🚀 New Features
- **Related PR**: [#666](https://github.com/higress-group/higress-console/pull/666) \
**Contributor**: @johnlanni \
**Change Log**: This PR adds support for `pluginImageRegistry` and `pluginImageNamespace` configuration to the built-in plugins and allows these configurations to be specified via environment variables, enabling users to customize plugin image locations without modifying the `plugins.properties` file. \
**Feature Value**: The new feature allows users to manage their application's plugin image sources more flexibly, enhancing system configurability and convenience, especially useful for users requiring specific image repositories or namespaces.
**Change Log**: Added configuration options for the plugin image registry and namespace. Supports dynamically specifying built-in WASM plugin image addresses via environment variables `HIGRESS_ADMIN_WASM_PLUGIN_IMAGE_REGISTRY`/`NAMESPACE`, eliminating the need to modify `plugins.properties`. Corresponding Helm Chart `values` parameters and deployment template rendering logic have also been integrated. \
**Feature Value**: Enables users to flexibly configure WASM plugin image sources across diverse network environments (e.g., private cloud, air-gapped environments), improving deployment flexibility and security; reduces operational overhead and mitigates maintenance difficulties and upgrade risks associated with hard-coded configurations.
- **Related PR**: [#665](https://github.com/higress-group/higress-console/pull/665) \
**Contributor**: @johnlanni \
**Change Log**: This PR introduces advanced configuration options for Zhipu AI and Claude, including custom domains, code plan mode switching, and API version settings. \
**Feature Value**: It enhances the flexibility and functionality of AI services, allowing users to control AI service behavior more precisely, particularly beneficial for scenarios needing optimized code generation capabilities.
**Change Log**: Added support for Zhipu AIs Code Plan mode and Claudes API version configuration. Achieved by extending `ZhipuAILlmProviderHandler` and `ClaudeLlmProviderHandler` to support custom domains, code-generation optimization toggles, and API version parameters—enhancing LLM invocation flexibility and scenario adaptability. \
**Feature Value**: Allows users to enable model-specific code generation modes (e.g., Zhipu Code Plan) based on AI vendor characteristics and precisely control Claude API versions, significantly improving code generation quality and compatibility, lowering integration barriers, and strengthening the practicality of the AI Gateway in multi-model collaborative development scenarios.
- **Related PR**: [#661](https://github.com/higress-group/higress-console/pull/661) \
**Contributor**: @johnlanni \
**Change Log**: This PR enables a lightweight mode for ai-statistics plugin configuration, adds the USE_DEFAULT_RESPONSE_ATTRIBUTES constant, and applies this setting in AiRouteServiceImpl. \
**Feature Value**: By enabling the lightweight mode, this feature optimizes AI routing performance, especially suitable for production environments, reducing response attribute buffering and improving system efficiency.
**Change Log**: Introduced a lightweight mode configuration for the AI statistics plugin. Added the `USE_DEFAULT_ATTRIBUTES` constant and enabled `use_default_response_attributes: true` in `AiRouteServiceImpl`, reducing response attribute collection overhead and preventing memory buffer issues. \
**Feature Value**: Improves production environment stability and performance while lowering resource consumption of AI route statistics; eliminates the need for manual configuration of complex attributes—the system automatically adopts a default, streamlined attribute set—simplifying operations and enhancing reliability under high-concurrency workloads.
- **Related PR**: [#657](https://github.com/higress-group/higress-console/pull/657) \
**Contributor**: @liangziccc \
**Change Log**: This PR removes the existing input box search function and adds multiple selection dropdown filters for route names, domain names, and other attributes, while also implementing language adaptation for Chinese and English. \
**Feature Value**: By adding multi-condition filtering, users can more accurately locate and manage specific route information, enhancing system usability and flexibility, which helps improve work efficiency.
**Change Log**: Removed the original text-input search from the Route Management page and introduced multi-select dropdown filters for five fields: Route Name, Domain, Route Conditions, Destination Service, and Request Authorization. Completed ChineseEnglish internationalization support and implemented multi-dimensional composite filtering (OR within each field, AND across fields), significantly improving data filtering precision. \
**Feature Value**: Enables users to quickly locate specific routes via intuitive dropdown selection, avoiding input errors; bilingual support accommodates international usage scenarios; multi-condition combined filtering substantially boosts query efficiency and operational experience for SREs managing large-scale route configurations.
### 🐛 Bug Fixes (Bug Fixes)
### 🐛 Bug Fixes
- **Related PR**: [#662](https://github.com/higress-group/higress-console/pull/662) \
**Contributor**: @johnlanni \
**Change Log**: This PR corrects the mcp-server OCI image path from `mcp-server/all-in-one` to `plugins/mcp-server`, aligning with the new plugin structure. \
**Feature Value**: Updating the image path ensures consistency with the new plugin directory, ensuring proper service operation and avoiding deployment or runtime issues due to incorrect paths.
**Change Log**: Fixed an issue where the OCI image path for the `mcp-server` plugin was not migrated synchronously—updated the original path `mcp-server/all-in-one` to `plugins/mcp-server` to align with the new plugin directory structure, ensuring correct plugin loading and deployment. \
**Feature Value**: Prevents plugin pull or startup failures caused by incorrect image paths, guaranteeing stable operation and seamless upgrades of the `mcp-server` plugin within the Higress Gateway, thereby enhancing deployment reliability in plugin-driven use cases.
- **Related PR**: [#654](https://github.com/higress-group/higress-console/pull/654) \
**Contributor**: @fgksking \
**Change Log**: This PR resolves the issue of empty request bodies displayed in Swagger UI by upgrading the springdoc's swagger-ui dependency, ensuring the accuracy of API documentation. \
**Feature Value**: Fixing the empty request body value issue in Swagger UI improves user experience and developer trust in API documentation, ensuring consistency between interface testing and actual usage.
**Change Log**: Upgraded the `swagger-ui` version dependency of `springdoc` by introducing a newer version of the `webjars-lo` dependency in `pom.xml` and updating related version properties, resolving an issue where request body schemas appeared empty in Swagger UI. \
**Feature Value**: Ensures users can correctly view and interact with request body structures when using the API documentation functionality in the Higress Console, improving API debugging experience and development efficiency—and preventing interface misinterpretations caused by documentation display anomalies.
---
## 📊 Release Statistics
- 🚀 New Features: 4
- 🐛 Bug Fixes: 2
- 🚀 New Features: 4
- 🐛 Bug Fixes: 2
**Total**: 6 changes
**Total**: 6 changes
Thank you to all contributors for your hard work! 🎉

View File

@@ -18,35 +18,35 @@
- **Related PR**: [#666](https://github.com/higress-group/higress-console/pull/666) \
**Contributor**: @johnlanni \
**Change Log**: 此PR向内置插件添加了`pluginImageRegistry``pluginImageNamespace`配置支持,并通过环境变量来指定这些配置,使用户可以在不修改`plugins.properties`文件的情况下自定义插件镜像的位置。 \
**Feature Value**: 新增的功能允许用户更灵活地管理其应用的插件镜像源,提高了系统的可配置性和便利性,特别对于需要使用特定镜像仓库或命名空间的用户来说十分有用
**Change Log**: 新增插件镜像仓库和命名空间配置项支持通过环境变量HIGRESS_ADMIN_WASM_PLUGIN_IMAGE_REGISTRY/NAMESPACE动态指定内置插件镜像地址无需修改plugins.properties同时在Helm Chart中集成对应values参数与部署模板渲染逻辑。 \
**Feature Value**: 用户可在不同网络环境如私有云、离线环境灵活配置WASM插件镜像源提升部署灵活性与安全性降低运维成本避免硬编码配置带来的维护困难和升级风险
- **Related PR**: [#665](https://github.com/higress-group/higress-console/pull/665) \
**Contributor**: @johnlanni \
**Change Log**: 本PR为Zhipu AI及Claude引入了高级配置选项支持包括自定义域、代码计划模式切换以及API版本设置。 \
**Feature Value**: 增强了AI服务的灵活性和功能性用户现在可以更精细地控制AI服务的行为特别是对于需要优化代码生成能力的场景特别有用
**Change Log**: 新增Zhipu AI的Code Plan模式支持和Claude的API版本配置能力通过扩展ZhipuAILlmProviderHandler和ClaudeLlmProviderHandler实现自定义域、代码生成优化开关及API版本参数提升大模型调用灵活性与场景适配性。 \
**Feature Value**: 用户可基于不同AI厂商特性启用代码专项生成模式如Zhipu Code Plan并精确控制Claude API版本显著提升代码生成质量与兼容性降低集成门槛增强AI网关在多模型协同开发场景中的实用性
- **Related PR**: [#661](https://github.com/higress-group/higress-console/pull/661) \
**Contributor**: @johnlanni \
**Change Log**: 此PR为ai-statistics插件配置启用了轻量模式添加了USE_DEFAULT_RESPONSE_ATTRIBUTES常量并在AiRouteServiceImpl中应用了这一设置。 \
**Feature Value**: 通过启用轻量模式此功能优化了AI路由的性能尤其适合生产环境减少了响应属性缓冲提升了系统效率
**Change Log**: 为AI统计插件引入轻量模式配置新增USE_DEFAULT_ATTRIBUTES常量并在AiRouteServiceImpl中启用use_default_response_attributes: true减少响应属性采集开销避免内存缓冲问题。 \
**Feature Value**: 提升生产环境稳定性与性能降低AI路由统计的资源消耗用户无需手动配置复杂属性系统自动采用默认精简属性集简化运维并增强高并发场景下的可靠性
- **Related PR**: [#657](https://github.com/higress-group/higress-console/pull/657) \
**Contributor**: @liangziccc \
**Change Log**: PR移除了原有输入框搜索功能,新增了针对路由名称、域名等多个属性的多选下拉筛选,并实现了中英文语言适配。 \
**Feature Value**: 通过增加多条件筛选功能,用户能够更精确地定位和管理特定路由信息,提升了系统的易用性和灵活性,有助于提高工作效率
**Change Log**: PR在路由管理页移除了原有输入框搜索,新增路由名称、域名、路由条件、目标服务、请求授权五个字段的多选下拉筛选,并完成中英文国际化适配支持多维度组合筛选各字段内OR、字段间AND提升数据过滤精准度。 \
**Feature Value**: 用户可通过直观下拉选择快速定位特定路由,避免手动输入错误;中英文切换支持国际化使用场景;多条件联合筛选显著提升运维人员在海量路由配置中的查询效率和操作体验
### 🐛 Bug修复 (Bug Fixes)
- **Related PR**: [#662](https://github.com/higress-group/higress-console/pull/662) \
**Contributor**: @johnlanni \
**Change Log**: 该PR修正了mcp-server OCI镜像路径`mcp-server/all-in-one``plugins/mcp-server`的变更,以匹配新插件结构。 \
**Feature Value**: 通过更新镜像路径确保与新插件目录一致,从而保证服务正常运行,避免因路径错误导致的部署或运行问题
**Change Log**: 修复了mcp-server插件OCI镜像路径未同步迁移的问题,将原路径mcp-server/all-in-one更新为plugins/mcp-server,适配新插件目录结构,确保插件加载和部署正常。 \
**Feature Value**: 避免因镜像路径错误导致插件无法拉取或启动失败保障Higress网关中mcp-server插件的稳定运行与无缝升级提升用户在插件化场景下的部署可靠性
- **Related PR**: [#654](https://github.com/higress-group/higress-console/pull/654) \
**Contributor**: @fgksking \
**Change Log**: 此PR通过升级springdoc内置的swagger-ui依赖解决了请求体显示为空的问题保证了API文档的准确性。 \
**Feature Value**: 修复了Swagger UI中空请求体值的问题提升了用户体验开发者对API文档的信任度确保接口测试与实际使用的一致性
**Change Log**: 升级springdoc依赖的swagger-ui版本通过在pom.xml中引入更高版本的webjars-lo依赖和更新相关版本属性修复了Swagger UI中请求体schema显示为空的问题。 \
**Feature Value**: 用户在使用Higress控制台的API文档功能时能正确查看和交互请求体结构提升API调试体验开发效率,避免因文档展示异常导致的接口调用误解
---