mirror of
https://github.com/alibaba/higress.git
synced 2026-02-28 06:30:49 +08:00
Compare commits
2 Commits
add-releas
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e2a22d1171 | ||
|
|
e9aecb6e1f |
@@ -225,9 +225,9 @@ func onHttpRequestHeader(ctx wrapper.HttpContext, pluginConfig config.PluginConf
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if contentType, _ := proxywasm.GetHttpRequestHeader(util.HeaderContentType); contentType != "" && !strings.Contains(contentType, util.MimeTypeApplicationJson) {
|
if contentType, _ := proxywasm.GetHttpRequestHeader(util.HeaderContentType); contentType != "" && !isSupportedRequestContentType(apiName, contentType) {
|
||||||
ctx.DontReadRequestBody()
|
ctx.DontReadRequestBody()
|
||||||
log.Debugf("[onHttpRequestHeader] unsupported content type: %s, will not process the request body", contentType)
|
log.Debugf("[onHttpRequestHeader] unsupported content type for api %s: %s, will not process the request body", apiName, contentType)
|
||||||
}
|
}
|
||||||
|
|
||||||
if apiName == "" {
|
if apiName == "" {
|
||||||
@@ -306,6 +306,7 @@ func onHttpRequestBody(ctx wrapper.HttpContext, pluginConfig config.PluginConfig
|
|||||||
if err == nil {
|
if err == nil {
|
||||||
return action
|
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))
|
_ = util.ErrorHandler("ai-proxy.proc_req_body_failed", fmt.Errorf("failed to process request body: %v", err))
|
||||||
}
|
}
|
||||||
return types.ActionContinue
|
return types.ActionContinue
|
||||||
@@ -594,3 +595,14 @@ func getApiName(path string) provider.ApiName {
|
|||||||
|
|
||||||
return ""
|
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
|
||||||
|
}
|
||||||
|
|||||||
@@ -63,6 +63,54 @@ 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) {
|
func TestAi360(t *testing.T) {
|
||||||
test.RunAi360ParseConfigTests(t)
|
test.RunAi360ParseConfigTests(t)
|
||||||
test.RunAi360OnHttpRequestHeadersTests(t)
|
test.RunAi360OnHttpRequestHeadersTests(t)
|
||||||
@@ -137,6 +185,8 @@ func TestVertex(t *testing.T) {
|
|||||||
test.RunVertexExpressModeOnStreamingResponseBodyTests(t)
|
test.RunVertexExpressModeOnStreamingResponseBodyTests(t)
|
||||||
test.RunVertexExpressModeImageGenerationRequestBodyTests(t)
|
test.RunVertexExpressModeImageGenerationRequestBodyTests(t)
|
||||||
test.RunVertexExpressModeImageGenerationResponseBodyTests(t)
|
test.RunVertexExpressModeImageGenerationResponseBodyTests(t)
|
||||||
|
test.RunVertexExpressModeImageEditVariationRequestBodyTests(t)
|
||||||
|
test.RunVertexExpressModeImageEditVariationResponseBodyTests(t)
|
||||||
// Vertex Raw 模式测试
|
// Vertex Raw 模式测试
|
||||||
test.RunVertexRawModeOnHttpRequestHeadersTests(t)
|
test.RunVertexRawModeOnHttpRequestHeadersTests(t)
|
||||||
test.RunVertexRawModeOnHttpRequestBodyTests(t)
|
test.RunVertexRawModeOnHttpRequestBodyTests(t)
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
package provider
|
package provider
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
@@ -461,6 +462,122 @@ type imageGenerationRequest struct {
|
|||||||
Size string `json:"size,omitempty"`
|
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 {
|
type imageGenerationData struct {
|
||||||
URL string `json:"url,omitempty"`
|
URL string `json:"url,omitempty"`
|
||||||
B64 string `json:"b64_json,omitempty"`
|
B64 string `json:"b64_json,omitempty"`
|
||||||
|
|||||||
156
plugins/wasm-go/extensions/ai-proxy/provider/multipart_helper.go
Normal file
156
plugins/wasm-go/extensions/ai-proxy/provider/multipart_helper.go
Normal file
@@ -0,0 +1,156 @@
|
|||||||
|
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
|
||||||
|
}
|
||||||
@@ -763,19 +763,19 @@ func (c *ProviderConfig) GetRandomToken() string {
|
|||||||
func isStatefulAPI(apiName string) bool {
|
func isStatefulAPI(apiName string) bool {
|
||||||
// These APIs maintain session state and should be routed to the same provider consistently
|
// These APIs maintain session state and should be routed to the same provider consistently
|
||||||
statefulAPIs := map[string]bool{
|
statefulAPIs := map[string]bool{
|
||||||
string(ApiNameResponses): true, // Response API - uses previous_response_id
|
string(ApiNameResponses): true, // Response API - uses previous_response_id
|
||||||
string(ApiNameFiles): true, // Files API - maintains file state
|
string(ApiNameFiles): true, // Files API - maintains file state
|
||||||
string(ApiNameRetrieveFile): true, // File retrieval - depends on file upload
|
string(ApiNameRetrieveFile): true, // File retrieval - depends on file upload
|
||||||
string(ApiNameRetrieveFileContent): true, // File content - depends on file upload
|
string(ApiNameRetrieveFileContent): true, // File content - depends on file upload
|
||||||
string(ApiNameBatches): true, // Batch API - maintains batch state
|
string(ApiNameBatches): true, // Batch API - maintains batch state
|
||||||
string(ApiNameRetrieveBatch): true, // Batch status - depends on batch creation
|
string(ApiNameRetrieveBatch): true, // Batch status - depends on batch creation
|
||||||
string(ApiNameCancelBatch): true, // Batch operations - depends on batch state
|
string(ApiNameCancelBatch): true, // Batch operations - depends on batch state
|
||||||
string(ApiNameFineTuningJobs): true, // Fine-tuning - maintains job state
|
string(ApiNameFineTuningJobs): true, // Fine-tuning - maintains job state
|
||||||
string(ApiNameRetrieveFineTuningJob): true, // Fine-tuning job status
|
string(ApiNameRetrieveFineTuningJob): true, // Fine-tuning job status
|
||||||
string(ApiNameFineTuningJobEvents): true, // Fine-tuning events
|
string(ApiNameFineTuningJobEvents): true, // Fine-tuning events
|
||||||
string(ApiNameFineTuningJobCheckpoints): true, // Fine-tuning checkpoints
|
string(ApiNameFineTuningJobCheckpoints): true, // Fine-tuning checkpoints
|
||||||
string(ApiNameCancelFineTuningJob): true, // Cancel fine-tuning job
|
string(ApiNameCancelFineTuningJob): true, // Cancel fine-tuning job
|
||||||
string(ApiNameResumeFineTuningJob): true, // Resume fine-tuning job
|
string(ApiNameResumeFineTuningJob): true, // Resume fine-tuning job
|
||||||
}
|
}
|
||||||
return statefulAPIs[apiName]
|
return statefulAPIs[apiName]
|
||||||
}
|
}
|
||||||
@@ -845,6 +845,16 @@ func (c *ProviderConfig) parseRequestAndMapModel(ctx wrapper.HttpContext, reques
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
return c.setRequestModel(ctx, req)
|
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:
|
default:
|
||||||
return errors.New("unsupported request type")
|
return errors.New("unsupported request type")
|
||||||
}
|
}
|
||||||
@@ -860,6 +870,10 @@ func (c *ProviderConfig) setRequestModel(ctx wrapper.HttpContext, request interf
|
|||||||
model = &req.Model
|
model = &req.Model
|
||||||
case *imageGenerationRequest:
|
case *imageGenerationRequest:
|
||||||
model = &req.Model
|
model = &req.Model
|
||||||
|
case *imageEditRequest:
|
||||||
|
model = &req.Model
|
||||||
|
case *imageVariationRequest:
|
||||||
|
model = &req.Model
|
||||||
default:
|
default:
|
||||||
return errors.New("unsupported request type")
|
return errors.New("unsupported request type")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -37,7 +37,7 @@ const (
|
|||||||
qwenCompatibleRetrieveBatchPath = "/compatible-mode/v1/batches/{batch_id}"
|
qwenCompatibleRetrieveBatchPath = "/compatible-mode/v1/batches/{batch_id}"
|
||||||
qwenBailianPath = "/api/v1/apps"
|
qwenBailianPath = "/api/v1/apps"
|
||||||
qwenMultimodalGenerationPath = "/api/v1/services/aigc/multimodal-generation/generation"
|
qwenMultimodalGenerationPath = "/api/v1/services/aigc/multimodal-generation/generation"
|
||||||
qwenAnthropicMessagesPath = "/api/v2/apps/claude-code-proxy/v1/messages"
|
qwenAnthropicMessagesPath = "/apps/anthropic/v1/messages"
|
||||||
|
|
||||||
qwenAsyncAIGCPath = "/api/v1/services/aigc/"
|
qwenAsyncAIGCPath = "/api/v1/services/aigc/"
|
||||||
qwenAsyncTaskPath = "/api/v1/tasks/"
|
qwenAsyncTaskPath = "/api/v1/tasks/"
|
||||||
|
|||||||
@@ -4,8 +4,8 @@ import (
|
|||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
|
||||||
"github.com/higress-group/wasm-go/pkg/log"
|
|
||||||
"github.com/higress-group/proxy-wasm-go-sdk/proxywasm"
|
"github.com/higress-group/proxy-wasm-go-sdk/proxywasm"
|
||||||
|
"github.com/higress-group/wasm-go/pkg/log"
|
||||||
)
|
)
|
||||||
|
|
||||||
func decodeChatCompletionRequest(body []byte, request *chatCompletionRequest) error {
|
func decodeChatCompletionRequest(body []byte, request *chatCompletionRequest) error {
|
||||||
@@ -32,6 +32,20 @@ func decodeImageGenerationRequest(body []byte, request *imageGenerationRequest)
|
|||||||
return nil
|
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 {
|
func replaceJsonRequestBody(request interface{}) error {
|
||||||
body, err := json.Marshal(request)
|
body, err := json.Marshal(request)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -46,6 +46,7 @@ const (
|
|||||||
contextOpenAICompatibleMarker = "isOpenAICompatibleRequest"
|
contextOpenAICompatibleMarker = "isOpenAICompatibleRequest"
|
||||||
contextVertexRawMarker = "isVertexRawRequest"
|
contextVertexRawMarker = "isVertexRawRequest"
|
||||||
vertexAnthropicVersion = "vertex-2023-10-16"
|
vertexAnthropicVersion = "vertex-2023-10-16"
|
||||||
|
vertexImageVariationDefaultPrompt = "Create variations of the provided image."
|
||||||
)
|
)
|
||||||
|
|
||||||
// vertexRawPathRegex 匹配原生 Vertex AI REST API 路径
|
// vertexRawPathRegex 匹配原生 Vertex AI REST API 路径
|
||||||
@@ -98,6 +99,8 @@ func (v *vertexProviderInitializer) DefaultCapabilities() map[string]string {
|
|||||||
string(ApiNameChatCompletion): vertexPathTemplate,
|
string(ApiNameChatCompletion): vertexPathTemplate,
|
||||||
string(ApiNameEmbeddings): vertexPathTemplate,
|
string(ApiNameEmbeddings): vertexPathTemplate,
|
||||||
string(ApiNameImageGeneration): vertexPathTemplate,
|
string(ApiNameImageGeneration): vertexPathTemplate,
|
||||||
|
string(ApiNameImageEdit): vertexPathTemplate,
|
||||||
|
string(ApiNameImageVariation): vertexPathTemplate,
|
||||||
string(ApiNameVertexRaw): "", // 空字符串表示保持原路径,不做路径转换
|
string(ApiNameVertexRaw): "", // 空字符串表示保持原路径,不做路径转换
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -307,6 +310,10 @@ func (v *vertexProvider) TransformRequestBodyHeaders(ctx wrapper.HttpContext, ap
|
|||||||
return v.onEmbeddingsRequestBody(ctx, body, headers)
|
return v.onEmbeddingsRequestBody(ctx, body, headers)
|
||||||
case ApiNameImageGeneration:
|
case ApiNameImageGeneration:
|
||||||
return v.onImageGenerationRequestBody(ctx, body, headers)
|
return v.onImageGenerationRequestBody(ctx, body, headers)
|
||||||
|
case ApiNameImageEdit:
|
||||||
|
return v.onImageEditRequestBody(ctx, body, headers)
|
||||||
|
case ApiNameImageVariation:
|
||||||
|
return v.onImageVariationRequestBody(ctx, body, headers)
|
||||||
default:
|
default:
|
||||||
return body, nil
|
return body, nil
|
||||||
}
|
}
|
||||||
@@ -387,11 +394,108 @@ func (v *vertexProvider) onImageGenerationRequestBody(ctx wrapper.HttpContext, b
|
|||||||
path := v.getRequestPath(ApiNameImageGeneration, request.Model, false)
|
path := v.getRequestPath(ApiNameImageGeneration, request.Model, false)
|
||||||
util.OverwriteRequestPathHeader(headers, path)
|
util.OverwriteRequestPathHeader(headers, path)
|
||||||
|
|
||||||
vertexRequest := v.buildVertexImageGenerationRequest(request)
|
vertexRequest, err := v.buildVertexImageGenerationRequest(request)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
return json.Marshal(vertexRequest)
|
return json.Marshal(vertexRequest)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (v *vertexProvider) buildVertexImageGenerationRequest(request *imageGenerationRequest) *vertexChatRequest {
|
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) {
|
||||||
// 构建安全设置
|
// 构建安全设置
|
||||||
safetySettings := make([]vertexChatSafetySetting, 0)
|
safetySettings := make([]vertexChatSafetySetting, 0)
|
||||||
for category, threshold := range v.config.geminiSafetySetting {
|
for category, threshold := range v.config.geminiSafetySetting {
|
||||||
@@ -402,12 +506,12 @@ func (v *vertexProvider) buildVertexImageGenerationRequest(request *imageGenerat
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 解析尺寸参数
|
// 解析尺寸参数
|
||||||
aspectRatio, imageSize := v.parseImageSize(request.Size)
|
aspectRatio, imageSize := v.parseImageSize(size)
|
||||||
|
|
||||||
// 确定输出 MIME 类型
|
// 确定输出 MIME 类型
|
||||||
mimeType := "image/png"
|
mimeType := "image/png"
|
||||||
if request.OutputFormat != "" {
|
if outputFormat != "" {
|
||||||
switch request.OutputFormat {
|
switch outputFormat {
|
||||||
case "jpeg", "jpg":
|
case "jpeg", "jpg":
|
||||||
mimeType = "image/jpeg"
|
mimeType = "image/jpeg"
|
||||||
case "webp":
|
case "webp":
|
||||||
@@ -417,12 +521,27 @@ func (v *vertexProvider) buildVertexImageGenerationRequest(request *imageGenerat
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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{
|
vertexRequest := &vertexChatRequest{
|
||||||
Contents: []vertexChatContent{{
|
Contents: []vertexChatContent{{
|
||||||
Role: roleUser,
|
Role: roleUser,
|
||||||
Parts: []vertexPart{{
|
Parts: parts,
|
||||||
Text: request.Prompt,
|
|
||||||
}},
|
|
||||||
}},
|
}},
|
||||||
SafetySettings: safetySettings,
|
SafetySettings: safetySettings,
|
||||||
GenerationConfig: vertexChatGenerationConfig{
|
GenerationConfig: vertexChatGenerationConfig{
|
||||||
@@ -440,7 +559,7 @@ func (v *vertexProvider) buildVertexImageGenerationRequest(request *imageGenerat
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
return vertexRequest
|
return vertexRequest, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// parseImageSize 解析 OpenAI 格式的尺寸字符串(如 "1024x1024")为 Vertex AI 的 aspectRatio 和 imageSize
|
// parseImageSize 解析 OpenAI 格式的尺寸字符串(如 "1024x1024")为 Vertex AI 的 aspectRatio 和 imageSize
|
||||||
@@ -553,7 +672,7 @@ func (v *vertexProvider) TransformResponseBody(ctx wrapper.HttpContext, apiName
|
|||||||
return v.onChatCompletionResponseBody(ctx, body)
|
return v.onChatCompletionResponseBody(ctx, body)
|
||||||
case ApiNameEmbeddings:
|
case ApiNameEmbeddings:
|
||||||
return v.onEmbeddingsResponseBody(ctx, body)
|
return v.onEmbeddingsResponseBody(ctx, body)
|
||||||
case ApiNameImageGeneration:
|
case ApiNameImageGeneration, ApiNameImageEdit, ApiNameImageVariation:
|
||||||
return v.onImageGenerationResponseBody(ctx, body)
|
return v.onImageGenerationResponseBody(ctx, body)
|
||||||
default:
|
default:
|
||||||
return body, nil
|
return body, nil
|
||||||
@@ -784,7 +903,7 @@ func (v *vertexProvider) getRequestPath(apiName ApiName, modelId string, stream
|
|||||||
switch apiName {
|
switch apiName {
|
||||||
case ApiNameEmbeddings:
|
case ApiNameEmbeddings:
|
||||||
action = vertexEmbeddingAction
|
action = vertexEmbeddingAction
|
||||||
case ApiNameImageGeneration:
|
case ApiNameImageGeneration, ApiNameImageEdit, ApiNameImageVariation:
|
||||||
// 图片生成使用非流式端点,需要完整响应
|
// 图片生成使用非流式端点,需要完整响应
|
||||||
action = vertexChatCompletionAction
|
action = vertexChatCompletionAction
|
||||||
default:
|
default:
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
package test
|
package test
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"bytes"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
|
"mime/multipart"
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
@@ -1273,6 +1275,324 @@ 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 模式测试 ====================
|
// ==================== Vertex Raw 模式测试 ====================
|
||||||
|
|
||||||
func RunVertexRawModeOnHttpRequestHeadersTests(t *testing.T) {
|
func RunVertexRawModeOnHttpRequestHeadersTests(t *testing.T) {
|
||||||
|
|||||||
@@ -7,55 +7,55 @@ This release includes **6** updates, covering feature enhancements, bug fixes, a
|
|||||||
|
|
||||||
### Distribution of Updates
|
### Distribution of Updates
|
||||||
|
|
||||||
- **New Features**: 4
|
- **New Features**: 4
|
||||||
- **Bug Fixes**: 2
|
- **Bug Fixes**: 2
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 📝 Full Change Log
|
## 📝 Full Changelog
|
||||||
|
|
||||||
### 🚀 New Features
|
### 🚀 New Features (Features)
|
||||||
|
|
||||||
- **Related PR**: [#666](https://github.com/higress-group/higress-console/pull/666) \
|
- **Related PR**: [#666](https://github.com/higress-group/higress-console/pull/666) \
|
||||||
**Contributor**: @johnlanni \
|
**Contributor**: @johnlanni \
|
||||||
**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. \
|
**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**: 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.
|
**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.
|
||||||
|
|
||||||
- **Related PR**: [#665](https://github.com/higress-group/higress-console/pull/665) \
|
- **Related PR**: [#665](https://github.com/higress-group/higress-console/pull/665) \
|
||||||
**Contributor**: @johnlanni \
|
**Contributor**: @johnlanni \
|
||||||
**Change Log**: Added support for Zhipu AI’s Code Plan mode and Claude’s 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. \
|
**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**: 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.
|
**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.
|
||||||
|
|
||||||
- **Related PR**: [#661](https://github.com/higress-group/higress-console/pull/661) \
|
- **Related PR**: [#661](https://github.com/higress-group/higress-console/pull/661) \
|
||||||
**Contributor**: @johnlanni \
|
**Contributor**: @johnlanni \
|
||||||
**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. \
|
**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**: 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.
|
**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.
|
||||||
|
|
||||||
- **Related PR**: [#657](https://github.com/higress-group/higress-console/pull/657) \
|
- **Related PR**: [#657](https://github.com/higress-group/higress-console/pull/657) \
|
||||||
**Contributor**: @liangziccc \
|
**Contributor**: @liangziccc \
|
||||||
**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 Chinese–English internationalization support and implemented multi-dimensional composite filtering (OR within each field, AND across fields), significantly improving data filtering precision. \
|
**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**: 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.
|
**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.
|
||||||
|
|
||||||
### 🐛 Bug Fixes
|
### 🐛 Bug Fixes (Bug Fixes)
|
||||||
|
|
||||||
- **Related PR**: [#662](https://github.com/higress-group/higress-console/pull/662) \
|
- **Related PR**: [#662](https://github.com/higress-group/higress-console/pull/662) \
|
||||||
**Contributor**: @johnlanni \
|
**Contributor**: @johnlanni \
|
||||||
**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. \
|
**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**: 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.
|
**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.
|
||||||
|
|
||||||
- **Related PR**: [#654](https://github.com/higress-group/higress-console/pull/654) \
|
- **Related PR**: [#654](https://github.com/higress-group/higress-console/pull/654) \
|
||||||
**Contributor**: @fgksking \
|
**Contributor**: @fgksking \
|
||||||
**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. \
|
**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**: 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.
|
**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.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 📊 Release Statistics
|
## 📊 Release Statistics
|
||||||
|
|
||||||
- 🚀 New Features: 4
|
- 🚀 New Features: 4
|
||||||
- 🐛 Bug Fixes: 2
|
- 🐛 Bug Fixes: 2
|
||||||
|
|
||||||
**Total**: 6 changes
|
**Total**: 6 changes
|
||||||
|
|
||||||
Thank you to all contributors for your hard work! 🎉
|
Thank you to all contributors for your hard work! 🎉
|
||||||
|
|
||||||
|
|||||||
@@ -18,35 +18,35 @@
|
|||||||
|
|
||||||
- **Related PR**: [#666](https://github.com/higress-group/higress-console/pull/666) \
|
- **Related PR**: [#666](https://github.com/higress-group/higress-console/pull/666) \
|
||||||
**Contributor**: @johnlanni \
|
**Contributor**: @johnlanni \
|
||||||
**Change Log**: 新增插件镜像仓库和命名空间配置项,支持通过环境变量HIGRESS_ADMIN_WASM_PLUGIN_IMAGE_REGISTRY/NAMESPACE动态指定内置插件镜像地址,无需修改plugins.properties;同时在Helm Chart中集成对应values参数与部署模板渲染逻辑。 \
|
**Change Log**: 此PR向内置插件添加了`pluginImageRegistry`和`pluginImageNamespace`配置支持,并通过环境变量来指定这些配置,使用户可以在不修改`plugins.properties`文件的情况下自定义插件镜像的位置。 \
|
||||||
**Feature Value**: 用户可在不同网络环境(如私有云、离线环境)灵活配置WASM插件镜像源,提升部署灵活性与安全性;降低运维成本,避免硬编码配置带来的维护困难和升级风险。
|
**Feature Value**: 新增的功能允许用户更灵活地管理其应用的插件镜像源,提高了系统的可配置性和便利性,特别对于需要使用特定镜像仓库或命名空间的用户来说十分有用。
|
||||||
|
|
||||||
- **Related PR**: [#665](https://github.com/higress-group/higress-console/pull/665) \
|
- **Related PR**: [#665](https://github.com/higress-group/higress-console/pull/665) \
|
||||||
**Contributor**: @johnlanni \
|
**Contributor**: @johnlanni \
|
||||||
**Change Log**: 新增Zhipu AI的Code Plan模式支持和Claude的API版本配置能力,通过扩展ZhipuAILlmProviderHandler和ClaudeLlmProviderHandler实现自定义域名、代码生成优化开关及API版本参数,提升大模型调用灵活性与场景适配性。 \
|
**Change Log**: 本PR为Zhipu AI及Claude引入了高级配置选项支持,包括自定义域、代码计划模式切换以及API版本设置。 \
|
||||||
**Feature Value**: 用户可基于不同AI厂商特性启用代码专项生成模式(如Zhipu Code Plan)并精确控制Claude API版本,显著提升代码生成质量与兼容性,降低集成门槛,增强AI网关在多模型协同开发场景中的实用性。
|
**Feature Value**: 增强了AI服务的灵活性和功能性,用户现在可以更精细地控制AI服务的行为,特别是对于需要优化代码生成能力的场景特别有用。
|
||||||
|
|
||||||
- **Related PR**: [#661](https://github.com/higress-group/higress-console/pull/661) \
|
- **Related PR**: [#661](https://github.com/higress-group/higress-console/pull/661) \
|
||||||
**Contributor**: @johnlanni \
|
**Contributor**: @johnlanni \
|
||||||
**Change Log**: 为AI统计插件引入轻量模式配置,新增USE_DEFAULT_ATTRIBUTES常量,并在AiRouteServiceImpl中启用use_default_response_attributes: true,减少响应属性采集开销,避免内存缓冲问题。 \
|
**Change Log**: 此PR为ai-statistics插件配置启用了轻量模式,添加了USE_DEFAULT_RESPONSE_ATTRIBUTES常量,并在AiRouteServiceImpl中应用了这一设置。 \
|
||||||
**Feature Value**: 提升生产环境稳定性与性能,降低AI路由统计的资源消耗;用户无需手动配置复杂属性,系统自动采用默认精简属性集,简化运维并增强高并发场景下的可靠性。
|
**Feature Value**: 通过启用轻量模式,此功能优化了AI路由的性能,尤其适合生产环境,减少了响应属性缓冲,提升了系统效率。
|
||||||
|
|
||||||
- **Related PR**: [#657](https://github.com/higress-group/higress-console/pull/657) \
|
- **Related PR**: [#657](https://github.com/higress-group/higress-console/pull/657) \
|
||||||
**Contributor**: @liangziccc \
|
**Contributor**: @liangziccc \
|
||||||
**Change Log**: PR在路由管理页移除了原有输入框搜索,新增路由名称、域名、路由条件、目标服务、请求授权五个字段的多选下拉筛选,并完成中英文国际化适配,支持多维度组合筛选(各字段内OR、字段间AND),提升数据过滤精准度。 \
|
**Change Log**: 此PR移除了原有的输入框搜索功能,新增了针对路由名称、域名等多个属性的多选下拉筛选框,并实现了中英文语言适配。 \
|
||||||
**Feature Value**: 用户可通过直观下拉选择快速定位特定路由,避免手动输入错误;中英文切换支持国际化使用场景;多条件联合筛选显著提升运维人员在海量路由配置中的查询效率和操作体验。
|
**Feature Value**: 通过增加多条件筛选功能,用户能够更精确地定位和管理特定路由信息,提升了系统的易用性和灵活性,有助于提高工作效率。
|
||||||
|
|
||||||
### 🐛 Bug修复 (Bug Fixes)
|
### 🐛 Bug修复 (Bug Fixes)
|
||||||
|
|
||||||
- **Related PR**: [#662](https://github.com/higress-group/higress-console/pull/662) \
|
- **Related PR**: [#662](https://github.com/higress-group/higress-console/pull/662) \
|
||||||
**Contributor**: @johnlanni \
|
**Contributor**: @johnlanni \
|
||||||
**Change Log**: 修复了mcp-server插件OCI镜像路径未同步迁移的问题,将原路径mcp-server/all-in-one更新为plugins/mcp-server,适配新插件目录结构,确保插件加载和部署正常。 \
|
**Change Log**: 该PR修正了mcp-server OCI镜像路径从`mcp-server/all-in-one`到`plugins/mcp-server`的变更,以匹配新的插件结构。 \
|
||||||
**Feature Value**: 避免因镜像路径错误导致插件无法拉取或启动失败,保障Higress网关中mcp-server插件的稳定运行与无缝升级,提升用户在插件化场景下的部署可靠性。
|
**Feature Value**: 通过更新镜像路径确保与新插件目录一致,从而保证服务正常运行,避免因路径错误导致的部署或运行问题。
|
||||||
|
|
||||||
- **Related PR**: [#654](https://github.com/higress-group/higress-console/pull/654) \
|
- **Related PR**: [#654](https://github.com/higress-group/higress-console/pull/654) \
|
||||||
**Contributor**: @fgksking \
|
**Contributor**: @fgksking \
|
||||||
**Change Log**: 升级了springdoc依赖的swagger-ui版本,通过在pom.xml中引入更高版本的webjars-lo依赖和更新相关版本属性,修复了Swagger UI中请求体schema显示为空的问题。 \
|
**Change Log**: 此PR通过升级springdoc内置的swagger-ui依赖解决了请求体显示为空的问题,保证了API文档的准确性。 \
|
||||||
**Feature Value**: 用户在使用Higress控制台的API文档功能时,能正确查看和交互请求体结构,提升API调试体验与开发效率,避免因文档展示异常导致的接口调用误解。
|
**Feature Value**: 修复了Swagger UI中空请求体值的问题,提升了用户体验和开发者对API文档的信任度,确保接口测试与实际使用的一致性。
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user