mirror of
https://github.com/alibaba/higress.git
synced 2026-03-17 00:40:48 +08:00
Compare commits
1 Commits
main
...
add-releas
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b446651dd3 |
5
.github/workflows/wasm-plugin-unit-test.yml
vendored
5
.github/workflows/wasm-plugin-unit-test.yml
vendored
@@ -199,14 +199,15 @@ jobs:
|
|||||||
- name: Checkout code
|
- name: Checkout code
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
- name: Set up Go 1.25
|
- name: Set up Go 1.24
|
||||||
uses: actions/setup-go@v4
|
uses: actions/setup-go@v4
|
||||||
with:
|
with:
|
||||||
go-version: 1.25
|
go-version: 1.24
|
||||||
cache: true
|
cache: true
|
||||||
|
|
||||||
- name: Install required tools
|
- name: Install required tools
|
||||||
run: |
|
run: |
|
||||||
|
go install github.com/wadey/gocovmerge@latest
|
||||||
sudo apt-get update && sudo apt-get install -y bc
|
sudo apt-get update && sudo apt-get install -y bc
|
||||||
|
|
||||||
- name: Download all test results
|
- name: Download all test results
|
||||||
|
|||||||
@@ -4,6 +4,6 @@ dependencies:
|
|||||||
version: 2.2.0
|
version: 2.2.0
|
||||||
- name: higress-console
|
- name: higress-console
|
||||||
repository: https://higress.io/helm-charts/
|
repository: https://higress.io/helm-charts/
|
||||||
version: 2.2.1
|
version: 2.2.0
|
||||||
digest: sha256:23fe7b0f84965c13ac7ceabe6334212fc3d323b7b781277a6d2b6fd38e935dda
|
digest: sha256:2cb148fa6d52856344e1905d3fea018466c2feb52013e08997c2d5c7d50f2e5d
|
||||||
generated: "2026-03-07T12:45:44.267732+08:00"
|
generated: "2026-02-11T17:45:59.187965929+08:00"
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
apiVersion: v2
|
apiVersion: v2
|
||||||
appVersion: 2.2.1
|
appVersion: 2.2.0
|
||||||
description: Helm chart for deploying Higress gateways
|
description: Helm chart for deploying Higress gateways
|
||||||
icon: https://higress.io/img/higress_logo_small.png
|
icon: https://higress.io/img/higress_logo_small.png
|
||||||
home: http://higress.io/
|
home: http://higress.io/
|
||||||
@@ -15,6 +15,6 @@ dependencies:
|
|||||||
version: 2.2.0
|
version: 2.2.0
|
||||||
- name: higress-console
|
- name: higress-console
|
||||||
repository: "https://higress.io/helm-charts/"
|
repository: "https://higress.io/helm-charts/"
|
||||||
version: 2.2.1
|
version: 2.2.0
|
||||||
type: application
|
type: application
|
||||||
version: 2.2.1
|
version: 2.2.0
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ package translation
|
|||||||
import (
|
import (
|
||||||
"sync"
|
"sync"
|
||||||
|
|
||||||
|
"istio.io/istio/pilot/pkg/model"
|
||||||
istiomodel "istio.io/istio/pilot/pkg/model"
|
istiomodel "istio.io/istio/pilot/pkg/model"
|
||||||
"istio.io/istio/pkg/config"
|
"istio.io/istio/pkg/config"
|
||||||
"istio.io/istio/pkg/config/schema/collection"
|
"istio.io/istio/pkg/config/schema/collection"
|
||||||
@@ -39,8 +40,8 @@ type IngressTranslation struct {
|
|||||||
ingressConfig *ingressconfig.IngressConfig
|
ingressConfig *ingressconfig.IngressConfig
|
||||||
kingressConfig *ingressconfig.KIngressConfig
|
kingressConfig *ingressconfig.KIngressConfig
|
||||||
mutex sync.RWMutex
|
mutex sync.RWMutex
|
||||||
higressRouteCache istiomodel.IngressRouteCollection
|
higressRouteCache model.IngressRouteCollection
|
||||||
higressDomainCache istiomodel.IngressDomainCollection
|
higressDomainCache model.IngressDomainCollection
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewIngressTranslation(localKubeClient kube.Client, xdsUpdater istiomodel.XDSUpdater, namespace string, options common.Options) *IngressTranslation {
|
func NewIngressTranslation(localKubeClient kube.Client, xdsUpdater istiomodel.XDSUpdater, namespace string, options common.Options) *IngressTranslation {
|
||||||
@@ -108,11 +109,11 @@ func (m *IngressTranslation) SetWatchErrorHandler(f func(r *cache.Reflector, err
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *IngressTranslation) GetIngressRoutes() istiomodel.IngressRouteCollection {
|
func (m *IngressTranslation) GetIngressRoutes() model.IngressRouteCollection {
|
||||||
m.mutex.RLock()
|
m.mutex.RLock()
|
||||||
defer m.mutex.RUnlock()
|
defer m.mutex.RUnlock()
|
||||||
ingressRouteCache := m.ingressConfig.GetIngressRoutes()
|
ingressRouteCache := m.ingressConfig.GetIngressRoutes()
|
||||||
m.higressRouteCache = istiomodel.IngressRouteCollection{}
|
m.higressRouteCache = model.IngressRouteCollection{}
|
||||||
m.higressRouteCache.Invalid = append(m.higressRouteCache.Invalid, ingressRouteCache.Invalid...)
|
m.higressRouteCache.Invalid = append(m.higressRouteCache.Invalid, ingressRouteCache.Invalid...)
|
||||||
m.higressRouteCache.Valid = append(m.higressRouteCache.Valid, ingressRouteCache.Valid...)
|
m.higressRouteCache.Valid = append(m.higressRouteCache.Valid, ingressRouteCache.Valid...)
|
||||||
if m.kingressConfig != nil {
|
if m.kingressConfig != nil {
|
||||||
@@ -124,12 +125,12 @@ func (m *IngressTranslation) GetIngressRoutes() istiomodel.IngressRouteCollectio
|
|||||||
return m.higressRouteCache
|
return m.higressRouteCache
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *IngressTranslation) GetIngressDomains() istiomodel.IngressDomainCollection {
|
func (m *IngressTranslation) GetIngressDomains() model.IngressDomainCollection {
|
||||||
m.mutex.RLock()
|
m.mutex.RLock()
|
||||||
defer m.mutex.RUnlock()
|
defer m.mutex.RUnlock()
|
||||||
ingressDomainCache := m.ingressConfig.GetIngressDomains()
|
ingressDomainCache := m.ingressConfig.GetIngressDomains()
|
||||||
|
|
||||||
m.higressDomainCache = istiomodel.IngressDomainCollection{}
|
m.higressDomainCache = model.IngressDomainCollection{}
|
||||||
m.higressDomainCache.Invalid = append(m.higressDomainCache.Invalid, ingressDomainCache.Invalid...)
|
m.higressDomainCache.Invalid = append(m.higressDomainCache.Invalid, ingressDomainCache.Invalid...)
|
||||||
m.higressDomainCache.Valid = append(m.higressDomainCache.Valid, ingressDomainCache.Valid...)
|
m.higressDomainCache.Valid = append(m.higressDomainCache.Valid, ingressDomainCache.Valid...)
|
||||||
if m.kingressConfig != nil {
|
if m.kingressConfig != nil {
|
||||||
|
|||||||
@@ -140,16 +140,10 @@ func (s *SSEServer) HandleSSE(cb api.FilterCallbackHandler, stopChan chan struct
|
|||||||
|
|
||||||
// Send the initial endpoint event
|
// Send the initial endpoint event
|
||||||
initialEvent := fmt.Sprintf("event: endpoint\ndata: %s\n\n", messageEndpoint)
|
initialEvent := fmt.Sprintf("event: endpoint\ndata: %s\n\n", messageEndpoint)
|
||||||
go func() {
|
err = s.redisClient.Publish(channel, initialEvent)
|
||||||
defer func() {
|
if err != nil {
|
||||||
if r := recover(); r != nil {
|
api.LogErrorf("Failed to send initial event: %v", err)
|
||||||
api.LogErrorf("Failed to send initial event: %v", r)
|
|
||||||
}
|
}
|
||||||
}()
|
|
||||||
defer cb.EncoderFilterCallbacks().RecoverPanic()
|
|
||||||
api.LogDebugf("SSE Send message: %s", initialEvent)
|
|
||||||
cb.EncoderFilterCallbacks().InjectData([]byte(initialEvent))
|
|
||||||
}()
|
|
||||||
|
|
||||||
// Start health check handler
|
// Start health check handler
|
||||||
go func() {
|
go func() {
|
||||||
|
|||||||
@@ -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()
|
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 == "" {
|
if apiName == "" {
|
||||||
@@ -306,7 +306,6 @@ 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
|
||||||
@@ -595,14 +594,3 @@ 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,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) {
|
func TestAi360(t *testing.T) {
|
||||||
test.RunAi360ParseConfigTests(t)
|
test.RunAi360ParseConfigTests(t)
|
||||||
test.RunAi360OnHttpRequestHeadersTests(t)
|
test.RunAi360OnHttpRequestHeadersTests(t)
|
||||||
@@ -185,8 +137,6 @@ 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)
|
||||||
@@ -199,7 +149,6 @@ func TestBedrock(t *testing.T) {
|
|||||||
test.RunBedrockOnHttpRequestBodyTests(t)
|
test.RunBedrockOnHttpRequestBodyTests(t)
|
||||||
test.RunBedrockOnHttpResponseHeadersTests(t)
|
test.RunBedrockOnHttpResponseHeadersTests(t)
|
||||||
test.RunBedrockOnHttpResponseBodyTests(t)
|
test.RunBedrockOnHttpResponseBodyTests(t)
|
||||||
test.RunBedrockOnStreamingResponseBodyTests(t)
|
|
||||||
test.RunBedrockToolCallTests(t)
|
test.RunBedrockToolCallTests(t)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -38,18 +38,6 @@ const (
|
|||||||
bedrockInvokeModelPath = "/model/%s/invoke"
|
bedrockInvokeModelPath = "/model/%s/invoke"
|
||||||
bedrockSignedHeaders = "host;x-amz-date"
|
bedrockSignedHeaders = "host;x-amz-date"
|
||||||
requestIdHeader = "X-Amzn-Requestid"
|
requestIdHeader = "X-Amzn-Requestid"
|
||||||
bedrockCacheTypeDefault = "default"
|
|
||||||
bedrockCacheTTL5m = "5m"
|
|
||||||
bedrockCacheTTL1h = "1h"
|
|
||||||
|
|
||||||
bedrockCachePointPositionSystemPrompt = "systemPrompt"
|
|
||||||
bedrockCachePointPositionLastUserMessage = "lastUserMessage"
|
|
||||||
bedrockCachePointPositionLastMessage = "lastMessage"
|
|
||||||
)
|
|
||||||
|
|
||||||
var (
|
|
||||||
bedrockConversePathPattern = regexp.MustCompile(`/model/[^/]+/converse(-stream)?$`)
|
|
||||||
bedrockInvokePathPattern = regexp.MustCompile(`/model/[^/]+/invoke(-with-response-stream)?$`)
|
|
||||||
)
|
)
|
||||||
|
|
||||||
type bedrockProviderInitializer struct{}
|
type bedrockProviderInitializer struct{}
|
||||||
@@ -179,7 +167,6 @@ func (b *bedrockProvider) convertEventFromBedrockToOpenAI(ctx wrapper.HttpContex
|
|||||||
CompletionTokens: bedrockEvent.Usage.OutputTokens,
|
CompletionTokens: bedrockEvent.Usage.OutputTokens,
|
||||||
PromptTokens: bedrockEvent.Usage.InputTokens,
|
PromptTokens: bedrockEvent.Usage.InputTokens,
|
||||||
TotalTokens: bedrockEvent.Usage.TotalTokens,
|
TotalTokens: bedrockEvent.Usage.TotalTokens,
|
||||||
PromptTokensDetails: buildPromptTokensDetails(bedrockEvent.Usage.CacheReadInputTokens),
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
openAIFormattedChunkBytes, _ := json.Marshal(openAIFormattedChunk)
|
openAIFormattedChunkBytes, _ := json.Marshal(openAIFormattedChunk)
|
||||||
@@ -643,24 +630,13 @@ func (b *bedrockProvider) GetProviderType() string {
|
|||||||
return providerTypeBedrock
|
return providerTypeBedrock
|
||||||
}
|
}
|
||||||
|
|
||||||
func (b *bedrockProvider) GetApiName(path string) ApiName {
|
|
||||||
switch {
|
|
||||||
case bedrockConversePathPattern.MatchString(path):
|
|
||||||
return ApiNameChatCompletion
|
|
||||||
case bedrockInvokePathPattern.MatchString(path):
|
|
||||||
return ApiNameImageGeneration
|
|
||||||
default:
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (b *bedrockProvider) OnRequestHeaders(ctx wrapper.HttpContext, apiName ApiName) error {
|
func (b *bedrockProvider) OnRequestHeaders(ctx wrapper.HttpContext, apiName ApiName) error {
|
||||||
b.config.handleRequestHeaders(b, ctx, apiName)
|
b.config.handleRequestHeaders(b, ctx, apiName)
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (b *bedrockProvider) TransformRequestHeaders(ctx wrapper.HttpContext, apiName ApiName, headers http.Header) {
|
func (b *bedrockProvider) TransformRequestHeaders(ctx wrapper.HttpContext, apiName ApiName, headers http.Header) {
|
||||||
util.OverwriteRequestHostHeader(headers, fmt.Sprintf(bedrockDefaultDomain, strings.TrimSpace(b.config.awsRegion)))
|
util.OverwriteRequestHostHeader(headers, fmt.Sprintf(bedrockDefaultDomain, b.config.awsRegion))
|
||||||
|
|
||||||
// If apiTokens is configured, set Bearer token authentication here
|
// If apiTokens is configured, set Bearer token authentication here
|
||||||
// This follows the same pattern as other providers (qwen, zhipuai, etc.)
|
// This follows the same pattern as other providers (qwen, zhipuai, etc.)
|
||||||
@@ -671,15 +647,6 @@ func (b *bedrockProvider) TransformRequestHeaders(ctx wrapper.HttpContext, apiNa
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (b *bedrockProvider) OnRequestBody(ctx wrapper.HttpContext, apiName ApiName, body []byte) (types.Action, error) {
|
func (b *bedrockProvider) OnRequestBody(ctx wrapper.HttpContext, apiName ApiName, body []byte) (types.Action, error) {
|
||||||
// In original protocol mode (e.g. /model/{modelId}/converse-stream), keep the body/path untouched
|
|
||||||
// and only apply auth headers.
|
|
||||||
if b.config.IsOriginal() {
|
|
||||||
headers := util.GetRequestHeaders()
|
|
||||||
b.setAuthHeaders(body, headers)
|
|
||||||
util.ReplaceRequestHeaders(headers)
|
|
||||||
return types.ActionContinue, replaceRequestBody(body)
|
|
||||||
}
|
|
||||||
|
|
||||||
if !b.config.isSupportedAPI(apiName) {
|
if !b.config.isSupportedAPI(apiName) {
|
||||||
return types.ActionContinue, errUnsupportedApiName
|
return types.ActionContinue, errUnsupportedApiName
|
||||||
}
|
}
|
||||||
@@ -687,25 +654,14 @@ func (b *bedrockProvider) OnRequestBody(ctx wrapper.HttpContext, apiName ApiName
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (b *bedrockProvider) TransformRequestBodyHeaders(ctx wrapper.HttpContext, apiName ApiName, body []byte, headers http.Header) ([]byte, error) {
|
func (b *bedrockProvider) TransformRequestBodyHeaders(ctx wrapper.HttpContext, apiName ApiName, body []byte, headers http.Header) ([]byte, error) {
|
||||||
var transformedBody []byte
|
|
||||||
var err error
|
|
||||||
switch apiName {
|
switch apiName {
|
||||||
case ApiNameChatCompletion:
|
case ApiNameChatCompletion:
|
||||||
transformedBody, err = b.onChatCompletionRequestBody(ctx, body, headers)
|
return b.onChatCompletionRequestBody(ctx, body, headers)
|
||||||
case ApiNameImageGeneration:
|
case ApiNameImageGeneration:
|
||||||
transformedBody, err = b.onImageGenerationRequestBody(ctx, body, headers)
|
return b.onImageGenerationRequestBody(ctx, body, headers)
|
||||||
default:
|
default:
|
||||||
transformedBody, err = b.config.defaultTransformRequestBody(ctx, apiName, body)
|
return b.config.defaultTransformRequestBody(ctx, apiName, body)
|
||||||
}
|
}
|
||||||
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
// Always apply auth after request body/path are finalized.
|
|
||||||
// For Bearer token mode this is a no-op; for AK/SK mode this generates SigV4 headers.
|
|
||||||
b.setAuthHeaders(transformedBody, headers)
|
|
||||||
return transformedBody, nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (b *bedrockProvider) TransformResponseBody(ctx wrapper.HttpContext, apiName ApiName, body []byte) ([]byte, error) {
|
func (b *bedrockProvider) TransformResponseBody(ctx wrapper.HttpContext, apiName ApiName, body []byte) ([]byte, error) {
|
||||||
@@ -759,7 +715,9 @@ func (b *bedrockProvider) buildBedrockImageGenerationRequest(origRequest *imageG
|
|||||||
Quality: origRequest.Quality,
|
Quality: origRequest.Quality,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
return json.Marshal(request)
|
requestBytes, err := json.Marshal(request)
|
||||||
|
b.setAuthHeaders(requestBytes, headers)
|
||||||
|
return requestBytes, err
|
||||||
}
|
}
|
||||||
|
|
||||||
func (b *bedrockProvider) buildBedrockImageGenerationResponse(bedrockResponse *bedrockImageGenerationResponse) *imageGenerationResponse {
|
func (b *bedrockProvider) buildBedrockImageGenerationResponse(bedrockResponse *bedrockImageGenerationResponse) *imageGenerationResponse {
|
||||||
@@ -839,13 +797,6 @@ func (b *bedrockProvider) buildBedrockTextGenerationRequest(origRequest *chatCom
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
if origRequest.PromptCacheKey != "" {
|
|
||||||
log.Warnf("bedrock provider ignores prompt_cache_key because Converse API has no equivalent field")
|
|
||||||
}
|
|
||||||
if cacheTTL, ok := mapPromptCacheRetentionToBedrockTTL(origRequest.PromptCacheRetention); ok {
|
|
||||||
addPromptCachePointsToBedrockRequest(request, cacheTTL, b.getPromptCachePointPositions())
|
|
||||||
}
|
|
||||||
|
|
||||||
if origRequest.ReasoningEffort != "" {
|
if origRequest.ReasoningEffort != "" {
|
||||||
thinkingBudget := 1024 // default
|
thinkingBudget := 1024 // default
|
||||||
switch origRequest.ReasoningEffort {
|
switch origRequest.ReasoningEffort {
|
||||||
@@ -896,7 +847,9 @@ func (b *bedrockProvider) buildBedrockTextGenerationRequest(origRequest *chatCom
|
|||||||
request.AdditionalModelRequestFields[key] = value
|
request.AdditionalModelRequestFields[key] = value
|
||||||
}
|
}
|
||||||
|
|
||||||
return json.Marshal(request)
|
requestBytes, err := json.Marshal(request)
|
||||||
|
b.setAuthHeaders(requestBytes, headers)
|
||||||
|
return requestBytes, err
|
||||||
}
|
}
|
||||||
|
|
||||||
func (b *bedrockProvider) buildChatCompletionResponse(ctx wrapper.HttpContext, bedrockResponse *bedrockConverseResponse) *chatCompletionResponse {
|
func (b *bedrockProvider) buildChatCompletionResponse(ctx wrapper.HttpContext, bedrockResponse *bedrockConverseResponse) *chatCompletionResponse {
|
||||||
@@ -950,7 +903,6 @@ func (b *bedrockProvider) buildChatCompletionResponse(ctx wrapper.HttpContext, b
|
|||||||
PromptTokens: bedrockResponse.Usage.InputTokens,
|
PromptTokens: bedrockResponse.Usage.InputTokens,
|
||||||
CompletionTokens: bedrockResponse.Usage.OutputTokens,
|
CompletionTokens: bedrockResponse.Usage.OutputTokens,
|
||||||
TotalTokens: bedrockResponse.Usage.TotalTokens,
|
TotalTokens: bedrockResponse.Usage.TotalTokens,
|
||||||
PromptTokensDetails: buildPromptTokensDetails(bedrockResponse.Usage.CacheReadInputTokens),
|
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -981,112 +933,6 @@ func stopReasonBedrock2OpenAI(reason string) string {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func mapPromptCacheRetentionToBedrockTTL(retention string) (string, bool) {
|
|
||||||
switch retention {
|
|
||||||
case "":
|
|
||||||
return "", false
|
|
||||||
case "in_memory":
|
|
||||||
return bedrockCacheTTL5m, true
|
|
||||||
case "24h":
|
|
||||||
return bedrockCacheTTL1h, true
|
|
||||||
default:
|
|
||||||
log.Warnf("unsupported prompt_cache_retention for bedrock mapping: %s", retention)
|
|
||||||
return "", false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (b *bedrockProvider) getPromptCachePointPositions() map[string]bool {
|
|
||||||
if b.config.bedrockPromptCachePointPositions == nil {
|
|
||||||
return map[string]bool{
|
|
||||||
bedrockCachePointPositionSystemPrompt: true,
|
|
||||||
bedrockCachePointPositionLastMessage: false,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
positions := map[string]bool{
|
|
||||||
bedrockCachePointPositionSystemPrompt: false,
|
|
||||||
bedrockCachePointPositionLastUserMessage: false,
|
|
||||||
bedrockCachePointPositionLastMessage: false,
|
|
||||||
}
|
|
||||||
for rawKey, enabled := range b.config.bedrockPromptCachePointPositions {
|
|
||||||
key := normalizeBedrockCachePointPosition(rawKey)
|
|
||||||
switch key {
|
|
||||||
case bedrockCachePointPositionSystemPrompt, bedrockCachePointPositionLastUserMessage, bedrockCachePointPositionLastMessage:
|
|
||||||
positions[key] = enabled
|
|
||||||
default:
|
|
||||||
log.Warnf("unsupported bedrockPromptCachePointPositions key: %s", rawKey)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return positions
|
|
||||||
}
|
|
||||||
|
|
||||||
func normalizeBedrockCachePointPosition(raw string) string {
|
|
||||||
key := strings.ToLower(raw)
|
|
||||||
key = strings.ReplaceAll(key, "_", "")
|
|
||||||
key = strings.ReplaceAll(key, "-", "")
|
|
||||||
switch key {
|
|
||||||
case "systemprompt":
|
|
||||||
return bedrockCachePointPositionSystemPrompt
|
|
||||||
case "lastusermessage":
|
|
||||||
return bedrockCachePointPositionLastUserMessage
|
|
||||||
case "lastmessage":
|
|
||||||
return bedrockCachePointPositionLastMessage
|
|
||||||
default:
|
|
||||||
return raw
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func addPromptCachePointsToBedrockRequest(request *bedrockTextGenRequest, cacheTTL string, positions map[string]bool) {
|
|
||||||
if positions[bedrockCachePointPositionSystemPrompt] && len(request.System) > 0 {
|
|
||||||
request.System = append(request.System, systemContentBlock{
|
|
||||||
CachePoint: &bedrockCachePoint{
|
|
||||||
Type: bedrockCacheTypeDefault,
|
|
||||||
TTL: cacheTTL,
|
|
||||||
},
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
lastUserMessageIndex := -1
|
|
||||||
if positions[bedrockCachePointPositionLastUserMessage] {
|
|
||||||
lastUserMessageIndex = findLastMessageIndexByRole(request.Messages, roleUser)
|
|
||||||
if lastUserMessageIndex >= 0 {
|
|
||||||
appendCachePointToBedrockMessage(request, lastUserMessageIndex, cacheTTL)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if positions[bedrockCachePointPositionLastMessage] && len(request.Messages) > 0 {
|
|
||||||
lastMessageIndex := len(request.Messages) - 1
|
|
||||||
if lastMessageIndex != lastUserMessageIndex {
|
|
||||||
appendCachePointToBedrockMessage(request, lastMessageIndex, cacheTTL)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func findLastMessageIndexByRole(messages []bedrockMessage, role string) int {
|
|
||||||
for i := len(messages) - 1; i >= 0; i-- {
|
|
||||||
if messages[i].Role == role {
|
|
||||||
return i
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return -1
|
|
||||||
}
|
|
||||||
|
|
||||||
func appendCachePointToBedrockMessage(request *bedrockTextGenRequest, messageIndex int, cacheTTL string) {
|
|
||||||
request.Messages[messageIndex].Content = append(request.Messages[messageIndex].Content, bedrockMessageContent{
|
|
||||||
CachePoint: &bedrockCachePoint{
|
|
||||||
Type: bedrockCacheTypeDefault,
|
|
||||||
TTL: cacheTTL,
|
|
||||||
},
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
func buildPromptTokensDetails(cacheReadInputTokens int) *promptTokensDetails {
|
|
||||||
if cacheReadInputTokens <= 0 {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
return &promptTokensDetails{
|
|
||||||
CachedTokens: cacheReadInputTokens,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
type bedrockTextGenRequest struct {
|
type bedrockTextGenRequest struct {
|
||||||
Messages []bedrockMessage `json:"messages"`
|
Messages []bedrockMessage `json:"messages"`
|
||||||
System []systemContentBlock `json:"system,omitempty"`
|
System []systemContentBlock `json:"system,omitempty"`
|
||||||
@@ -1135,17 +981,10 @@ type bedrockMessageContent struct {
|
|||||||
Image *imageBlock `json:"image,omitempty"`
|
Image *imageBlock `json:"image,omitempty"`
|
||||||
ToolResult *toolResultBlock `json:"toolResult,omitempty"`
|
ToolResult *toolResultBlock `json:"toolResult,omitempty"`
|
||||||
ToolUse *toolUseBlock `json:"toolUse,omitempty"`
|
ToolUse *toolUseBlock `json:"toolUse,omitempty"`
|
||||||
CachePoint *bedrockCachePoint `json:"cachePoint,omitempty"`
|
|
||||||
}
|
}
|
||||||
|
|
||||||
type systemContentBlock struct {
|
type systemContentBlock struct {
|
||||||
Text string `json:"text,omitempty"`
|
Text string `json:"text,omitempty"`
|
||||||
CachePoint *bedrockCachePoint `json:"cachePoint,omitempty"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type bedrockCachePoint struct {
|
|
||||||
Type string `json:"type"`
|
|
||||||
TTL string `json:"ttl,omitempty"`
|
|
||||||
}
|
}
|
||||||
|
|
||||||
type imageBlock struct {
|
type imageBlock struct {
|
||||||
@@ -1227,10 +1066,6 @@ type tokenUsage struct {
|
|||||||
OutputTokens int `json:"outputTokens,omitempty"`
|
OutputTokens int `json:"outputTokens,omitempty"`
|
||||||
|
|
||||||
TotalTokens int `json:"totalTokens"`
|
TotalTokens int `json:"totalTokens"`
|
||||||
|
|
||||||
CacheReadInputTokens int `json:"cacheReadInputTokens,omitempty"`
|
|
||||||
|
|
||||||
CacheWriteInputTokens int `json:"cacheWriteInputTokens,omitempty"`
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func chatToolMessage2BedrockToolResultContent(chatMessage chatMessage) bedrockMessageContent {
|
func chatToolMessage2BedrockToolResultContent(chatMessage chatMessage) bedrockMessageContent {
|
||||||
@@ -1328,45 +1163,35 @@ func (b *bedrockProvider) setAuthHeaders(body []byte, headers http.Header) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Use AWS Signature V4 authentication
|
// Use AWS Signature V4 authentication
|
||||||
accessKey := strings.TrimSpace(b.config.awsAccessKey)
|
|
||||||
region := strings.TrimSpace(b.config.awsRegion)
|
|
||||||
t := time.Now().UTC()
|
t := time.Now().UTC()
|
||||||
amzDate := t.Format("20060102T150405Z")
|
amzDate := t.Format("20060102T150405Z")
|
||||||
dateStamp := t.Format("20060102")
|
dateStamp := t.Format("20060102")
|
||||||
path := headers.Get(":path")
|
path := headers.Get(":path")
|
||||||
signature := b.generateSignature(path, amzDate, dateStamp, body)
|
signature := b.generateSignature(path, amzDate, dateStamp, body)
|
||||||
headers.Set("X-Amz-Date", amzDate)
|
headers.Set("X-Amz-Date", amzDate)
|
||||||
util.OverwriteRequestAuthorizationHeader(headers, fmt.Sprintf("AWS4-HMAC-SHA256 Credential=%s/%s/%s/%s/aws4_request, SignedHeaders=%s, Signature=%s", accessKey, dateStamp, region, awsService, bedrockSignedHeaders, signature))
|
util.OverwriteRequestAuthorizationHeader(headers, fmt.Sprintf("AWS4-HMAC-SHA256 Credential=%s/%s/%s/%s/aws4_request, SignedHeaders=%s, Signature=%s", b.config.awsAccessKey, dateStamp, b.config.awsRegion, awsService, bedrockSignedHeaders, signature))
|
||||||
}
|
}
|
||||||
|
|
||||||
func (b *bedrockProvider) generateSignature(path, amzDate, dateStamp string, body []byte) string {
|
func (b *bedrockProvider) generateSignature(path, amzDate, dateStamp string, body []byte) string {
|
||||||
canonicalURI := encodeSigV4Path(path)
|
path = encodeSigV4Path(path)
|
||||||
hashedPayload := sha256Hex(body)
|
hashedPayload := sha256Hex(body)
|
||||||
region := strings.TrimSpace(b.config.awsRegion)
|
|
||||||
secretKey := strings.TrimSpace(b.config.awsSecretKey)
|
|
||||||
|
|
||||||
endpoint := fmt.Sprintf(bedrockDefaultDomain, region)
|
endpoint := fmt.Sprintf(bedrockDefaultDomain, b.config.awsRegion)
|
||||||
canonicalHeaders := fmt.Sprintf("host:%s\nx-amz-date:%s\n", endpoint, amzDate)
|
canonicalHeaders := fmt.Sprintf("host:%s\nx-amz-date:%s\n", endpoint, amzDate)
|
||||||
canonicalRequest := fmt.Sprintf("%s\n%s\n\n%s\n%s\n%s",
|
canonicalRequest := fmt.Sprintf("%s\n%s\n\n%s\n%s\n%s",
|
||||||
httpPostMethod, canonicalURI, canonicalHeaders, bedrockSignedHeaders, hashedPayload)
|
httpPostMethod, path, canonicalHeaders, bedrockSignedHeaders, hashedPayload)
|
||||||
|
|
||||||
credentialScope := fmt.Sprintf("%s/%s/%s/aws4_request", dateStamp, region, awsService)
|
credentialScope := fmt.Sprintf("%s/%s/%s/aws4_request", dateStamp, b.config.awsRegion, awsService)
|
||||||
hashedCanonReq := sha256Hex([]byte(canonicalRequest))
|
hashedCanonReq := sha256Hex([]byte(canonicalRequest))
|
||||||
stringToSign := fmt.Sprintf("AWS4-HMAC-SHA256\n%s\n%s\n%s",
|
stringToSign := fmt.Sprintf("AWS4-HMAC-SHA256\n%s\n%s\n%s",
|
||||||
amzDate, credentialScope, hashedCanonReq)
|
amzDate, credentialScope, hashedCanonReq)
|
||||||
|
|
||||||
signingKey := getSignatureKey(secretKey, dateStamp, region, awsService)
|
signingKey := getSignatureKey(b.config.awsSecretKey, dateStamp, b.config.awsRegion, awsService)
|
||||||
signature := hmacHex(signingKey, stringToSign)
|
signature := hmacHex(signingKey, stringToSign)
|
||||||
return signature
|
return signature
|
||||||
}
|
}
|
||||||
|
|
||||||
func encodeSigV4Path(path string) string {
|
func encodeSigV4Path(path string) string {
|
||||||
// Keep only the URI path for canonical URI. Query string is handled separately in SigV4,
|
|
||||||
// and this implementation uses an empty canonical query string.
|
|
||||||
if queryIndex := strings.Index(path, "?"); queryIndex >= 0 {
|
|
||||||
path = path[:queryIndex]
|
|
||||||
}
|
|
||||||
|
|
||||||
segments := strings.Split(path, "/")
|
segments := strings.Split(path, "/")
|
||||||
for i, seg := range segments {
|
for i, seg := range segments {
|
||||||
if seg == "" {
|
if seg == "" {
|
||||||
|
|||||||
@@ -1,104 +0,0 @@
|
|||||||
package provider
|
|
||||||
|
|
||||||
import (
|
|
||||||
"net/http"
|
|
||||||
"net/url"
|
|
||||||
"testing"
|
|
||||||
|
|
||||||
"github.com/stretchr/testify/assert"
|
|
||||||
)
|
|
||||||
|
|
||||||
func TestEncodeSigV4Path(t *testing.T) {
|
|
||||||
tests := []struct {
|
|
||||||
name string
|
|
||||||
path string
|
|
||||||
want string
|
|
||||||
}{
|
|
||||||
{
|
|
||||||
name: "raw model id keeps colon",
|
|
||||||
path: "/model/global.amazon.nova-2-lite-v1:0/converse-stream",
|
|
||||||
want: "/model/global.amazon.nova-2-lite-v1:0/converse-stream",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "pre-encoded model id escapes percent to avoid mismatch",
|
|
||||||
path: "/model/global.amazon.nova-2-lite-v1%3A0/converse-stream",
|
|
||||||
want: "/model/global.amazon.nova-2-lite-v1%253A0/converse-stream",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "raw inference profile arn keeps colon and slash delimiters",
|
|
||||||
path: "/model/arn:aws:bedrock:us-east-1:123456789012:inference-profile/global.anthropic.claude-sonnet-4-20250514-v1:0/converse",
|
|
||||||
want: "/model/arn:aws:bedrock:us-east-1:123456789012:inference-profile/global.anthropic.claude-sonnet-4-20250514-v1:0/converse",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "encoded inference profile arn preserves escaped slash as double-escaped percent",
|
|
||||||
path: "/model/arn%3Aaws%3Abedrock%3Aus-east-1%3A123456789012%3Ainference-profile%2Fglobal.anthropic.claude-sonnet-4-20250514-v1%3A0/converse",
|
|
||||||
want: "/model/arn%253Aaws%253Abedrock%253Aus-east-1%253A123456789012%253Ainference-profile%252Fglobal.anthropic.claude-sonnet-4-20250514-v1%253A0/converse",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "query string is stripped before canonical encoding",
|
|
||||||
path: "/model/global.amazon.nova-2-lite-v1%3A0/converse-stream?trace=1&foo=bar",
|
|
||||||
want: "/model/global.amazon.nova-2-lite-v1%253A0/converse-stream",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "invalid percent sequence falls back to escaped percent",
|
|
||||||
path: "/model/abc%ZZxyz/converse",
|
|
||||||
want: "/model/abc%25ZZxyz/converse",
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, tt := range tests {
|
|
||||||
t.Run(tt.name, func(t *testing.T) {
|
|
||||||
assert.Equal(t, tt.want, encodeSigV4Path(tt.path))
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestOverwriteRequestPathHeaderPreservesSingleEncodedRequestPath(t *testing.T) {
|
|
||||||
p := &bedrockProvider{}
|
|
||||||
plainModel := "arn:aws:bedrock:us-east-1:123456789012:inference-profile/global.amazon.nova-2-lite-v1:0"
|
|
||||||
preEncodedModel := url.QueryEscape(plainModel)
|
|
||||||
|
|
||||||
t.Run("plain model is encoded once", func(t *testing.T) {
|
|
||||||
headers := http.Header{}
|
|
||||||
p.overwriteRequestPathHeader(headers, bedrockChatCompletionPath, plainModel)
|
|
||||||
assert.Equal(t, "/model/arn%3Aaws%3Abedrock%3Aus-east-1%3A123456789012%3Ainference-profile%2Fglobal.amazon.nova-2-lite-v1%3A0/converse", headers.Get(":path"))
|
|
||||||
})
|
|
||||||
|
|
||||||
t.Run("pre-encoded model is not double encoded", func(t *testing.T) {
|
|
||||||
headers := http.Header{}
|
|
||||||
p.overwriteRequestPathHeader(headers, bedrockChatCompletionPath, preEncodedModel)
|
|
||||||
assert.Equal(t, "/model/arn%3Aaws%3Abedrock%3Aus-east-1%3A123456789012%3Ainference-profile%2Fglobal.amazon.nova-2-lite-v1%3A0/converse", headers.Get(":path"))
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestGenerateSignatureIgnoresQueryStringInCanonicalURI(t *testing.T) {
|
|
||||||
p := &bedrockProvider{
|
|
||||||
config: ProviderConfig{
|
|
||||||
awsRegion: "ap-northeast-3",
|
|
||||||
awsSecretKey: "test-secret",
|
|
||||||
},
|
|
||||||
}
|
|
||||||
body := []byte(`{"messages":[{"role":"user","content":[{"text":"hello"}]}]}`)
|
|
||||||
pathWithoutQuery := "/model/global.amazon.nova-2-lite-v1%3A0/converse-stream"
|
|
||||||
pathWithQuery := pathWithoutQuery + "?trace=1&foo=bar"
|
|
||||||
|
|
||||||
sigWithoutQuery := p.generateSignature(pathWithoutQuery, "20260312T142942Z", "20260312", body)
|
|
||||||
sigWithQuery := p.generateSignature(pathWithQuery, "20260312T142942Z", "20260312", body)
|
|
||||||
assert.Equal(t, sigWithoutQuery, sigWithQuery)
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestGenerateSignatureDiffersForRawAndPreEncodedModelPath(t *testing.T) {
|
|
||||||
p := &bedrockProvider{
|
|
||||||
config: ProviderConfig{
|
|
||||||
awsRegion: "ap-northeast-3",
|
|
||||||
awsSecretKey: "test-secret",
|
|
||||||
},
|
|
||||||
}
|
|
||||||
body := []byte(`{"messages":[{"role":"user","content":[{"text":"hello"}]}]}`)
|
|
||||||
rawPath := "/model/global.amazon.nova-2-lite-v1:0/converse-stream"
|
|
||||||
preEncodedPath := "/model/global.amazon.nova-2-lite-v1%3A0/converse-stream"
|
|
||||||
|
|
||||||
rawSignature := p.generateSignature(rawPath, "20260312T142942Z", "20260312", body)
|
|
||||||
preEncodedSignature := p.generateSignature(preEncodedPath, "20260312T142942Z", "20260312", body)
|
|
||||||
assert.NotEqual(t, rawSignature, preEncodedSignature)
|
|
||||||
}
|
|
||||||
@@ -1,7 +1,6 @@
|
|||||||
package provider
|
package provider
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"encoding/json"
|
|
||||||
"fmt"
|
"fmt"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
@@ -64,8 +63,6 @@ type chatCompletionRequest struct {
|
|||||||
Stop []string `json:"stop,omitempty"`
|
Stop []string `json:"stop,omitempty"`
|
||||||
Stream bool `json:"stream,omitempty"`
|
Stream bool `json:"stream,omitempty"`
|
||||||
StreamOptions *streamOptions `json:"stream_options,omitempty"`
|
StreamOptions *streamOptions `json:"stream_options,omitempty"`
|
||||||
PromptCacheRetention string `json:"prompt_cache_retention,omitempty"`
|
|
||||||
PromptCacheKey string `json:"prompt_cache_key,omitempty"`
|
|
||||||
Temperature float64 `json:"temperature,omitempty"`
|
Temperature float64 `json:"temperature,omitempty"`
|
||||||
TopP float64 `json:"top_p,omitempty"`
|
TopP float64 `json:"top_p,omitempty"`
|
||||||
Tools []tool `json:"tools,omitempty"`
|
Tools []tool `json:"tools,omitempty"`
|
||||||
@@ -464,122 +461,6 @@ 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"`
|
||||||
|
|||||||
@@ -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
|
|
||||||
}
|
|
||||||
@@ -198,6 +198,7 @@ var (
|
|||||||
|
|
||||||
// Providers that support the "developer" role. Other providers will have "developer" roles converted to "system".
|
// Providers that support the "developer" role. Other providers will have "developer" roles converted to "system".
|
||||||
developerRoleSupportedProviders = map[string]bool{
|
developerRoleSupportedProviders = map[string]bool{
|
||||||
|
providerTypeOpenAI: true,
|
||||||
providerTypeAzure: true,
|
providerTypeAzure: true,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -354,9 +355,6 @@ type ProviderConfig struct {
|
|||||||
// @Title zh-CN Amazon Bedrock 额外模型请求参数
|
// @Title zh-CN Amazon Bedrock 额外模型请求参数
|
||||||
// @Description zh-CN 仅适用于Amazon Bedrock服务,用于设置模型特定的推理参数
|
// @Description zh-CN 仅适用于Amazon Bedrock服务,用于设置模型特定的推理参数
|
||||||
bedrockAdditionalFields map[string]interface{} `required:"false" yaml:"bedrockAdditionalFields" json:"bedrockAdditionalFields"`
|
bedrockAdditionalFields map[string]interface{} `required:"false" yaml:"bedrockAdditionalFields" json:"bedrockAdditionalFields"`
|
||||||
// @Title zh-CN Amazon Bedrock Prompt CachePoint 插入位置
|
|
||||||
// @Description zh-CN 仅适用于Amazon Bedrock服务。用于配置 cachePoint 插入位置,支持多选:systemPrompt、lastUserMessage、lastMessage。值为 true 表示启用该位置。
|
|
||||||
bedrockPromptCachePointPositions map[string]bool `required:"false" yaml:"bedrockPromptCachePointPositions" json:"bedrockPromptCachePointPositions"`
|
|
||||||
// @Title zh-CN minimax API type
|
// @Title zh-CN minimax API type
|
||||||
// @Description zh-CN 仅适用于 minimax 服务。minimax API 类型,v2 和 pro 中选填一项,默认值为 v2
|
// @Description zh-CN 仅适用于 minimax 服务。minimax API 类型,v2 和 pro 中选填一项,默认值为 v2
|
||||||
minimaxApiType string `required:"false" yaml:"minimaxApiType" json:"minimaxApiType"`
|
minimaxApiType string `required:"false" yaml:"minimaxApiType" json:"minimaxApiType"`
|
||||||
@@ -462,9 +460,6 @@ type ProviderConfig struct {
|
|||||||
// @Title zh-CN 智谱AI Code Plan 模式
|
// @Title zh-CN 智谱AI Code Plan 模式
|
||||||
// @Description zh-CN 仅适用于智谱AI服务。启用后将使用 /api/coding/paas/v4/chat/completions 接口
|
// @Description zh-CN 仅适用于智谱AI服务。启用后将使用 /api/coding/paas/v4/chat/completions 接口
|
||||||
zhipuCodePlanMode bool `required:"false" yaml:"zhipuCodePlanMode" json:"zhipuCodePlanMode"`
|
zhipuCodePlanMode bool `required:"false" yaml:"zhipuCodePlanMode" json:"zhipuCodePlanMode"`
|
||||||
// @Title zh-CN 合并连续同角色消息
|
|
||||||
// @Description zh-CN 开启后,若请求的 messages 中存在连续的同角色消息(如连续两条 user 消息),将其内容合并为一条,以满足要求严格轮流交替(user→assistant→user→...)的模型服务商的要求。
|
|
||||||
mergeConsecutiveMessages bool `required:"false" yaml:"mergeConsecutiveMessages" json:"mergeConsecutiveMessages"`
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c *ProviderConfig) GetId() string {
|
func (c *ProviderConfig) GetId() string {
|
||||||
@@ -558,12 +553,6 @@ func (c *ProviderConfig) FromJson(json gjson.Result) {
|
|||||||
for k, v := range json.Get("bedrockAdditionalFields").Map() {
|
for k, v := range json.Get("bedrockAdditionalFields").Map() {
|
||||||
c.bedrockAdditionalFields[k] = v.Value()
|
c.bedrockAdditionalFields[k] = v.Value()
|
||||||
}
|
}
|
||||||
if rawPositions := json.Get("bedrockPromptCachePointPositions"); rawPositions.Exists() {
|
|
||||||
c.bedrockPromptCachePointPositions = make(map[string]bool)
|
|
||||||
for k, v := range rawPositions.Map() {
|
|
||||||
c.bedrockPromptCachePointPositions[k] = v.Bool()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
c.minimaxApiType = json.Get("minimaxApiType").String()
|
c.minimaxApiType = json.Get("minimaxApiType").String()
|
||||||
c.minimaxGroupId = json.Get("minimaxGroupId").String()
|
c.minimaxGroupId = json.Get("minimaxGroupId").String()
|
||||||
@@ -684,7 +673,6 @@ func (c *ProviderConfig) FromJson(json gjson.Result) {
|
|||||||
c.contextCleanupCommands = append(c.contextCleanupCommands, cmd.String())
|
c.contextCleanupCommands = append(c.contextCleanupCommands, cmd.String())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
c.mergeConsecutiveMessages = json.Get("mergeConsecutiveMessages").Bool()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c *ProviderConfig) Validate() error {
|
func (c *ProviderConfig) Validate() error {
|
||||||
@@ -857,16 +845,6 @@ 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")
|
||||||
}
|
}
|
||||||
@@ -882,10 +860,6 @@ 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")
|
||||||
}
|
}
|
||||||
@@ -1046,7 +1020,7 @@ func ExtractStreamingEvents(ctx wrapper.HttpContext, chunk []byte) []StreamEvent
|
|||||||
if lineStartIndex != -1 {
|
if lineStartIndex != -1 {
|
||||||
value := string(body[valueStartIndex:i])
|
value := string(body[valueStartIndex:i])
|
||||||
currentEvent.SetValue(currentKey, value)
|
currentEvent.SetValue(currentKey, value)
|
||||||
} else if eventStartIndex != -1 {
|
} else {
|
||||||
currentEvent.RawEvent = string(body[eventStartIndex : i+1])
|
currentEvent.RawEvent = string(body[eventStartIndex : i+1])
|
||||||
// Extra new line. The current event is complete.
|
// Extra new line. The current event is complete.
|
||||||
events = append(events, *currentEvent)
|
events = append(events, *currentEvent)
|
||||||
@@ -1124,17 +1098,6 @@ func (c *ProviderConfig) handleRequestBody(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// merge consecutive same-role messages for providers that require strict role alternation
|
|
||||||
if apiName == ApiNameChatCompletion && c.mergeConsecutiveMessages {
|
|
||||||
body, err = mergeConsecutiveMessages(body)
|
|
||||||
if err != nil {
|
|
||||||
log.Warnf("[mergeConsecutiveMessages] failed to merge messages: %v", err)
|
|
||||||
err = nil
|
|
||||||
} else {
|
|
||||||
log.Debugf("[mergeConsecutiveMessages] merged consecutive messages for provider: %s", c.typ)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// convert developer role to system role for providers that don't support it
|
// convert developer role to system role for providers that don't support it
|
||||||
if apiName == ApiNameChatCompletion && !isDeveloperRoleSupported(c.typ) {
|
if apiName == ApiNameChatCompletion && !isDeveloperRoleSupported(c.typ) {
|
||||||
body, err = convertDeveloperRoleToSystem(body)
|
body, err = convertDeveloperRoleToSystem(body)
|
||||||
|
|||||||
@@ -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 = "/apps/anthropic/v1/messages"
|
qwenAnthropicMessagesPath = "/api/v2/apps/claude-code-proxy/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/proxy-wasm-go-sdk/proxywasm"
|
|
||||||
"github.com/higress-group/wasm-go/pkg/log"
|
"github.com/higress-group/wasm-go/pkg/log"
|
||||||
|
"github.com/higress-group/proxy-wasm-go-sdk/proxywasm"
|
||||||
)
|
)
|
||||||
|
|
||||||
func decodeChatCompletionRequest(body []byte, request *chatCompletionRequest) error {
|
func decodeChatCompletionRequest(body []byte, request *chatCompletionRequest) error {
|
||||||
@@ -32,20 +32,6 @@ 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 {
|
||||||
@@ -154,54 +140,6 @@ func cleanupContextMessages(body []byte, cleanupCommands []string) ([]byte, erro
|
|||||||
return json.Marshal(request)
|
return json.Marshal(request)
|
||||||
}
|
}
|
||||||
|
|
||||||
// mergeConsecutiveMessages merges consecutive messages of the same role (user or assistant).
|
|
||||||
// Many LLM providers require strict user↔assistant alternation and reject requests where
|
|
||||||
// two messages of the same role appear consecutively. When enabled, consecutive same-role
|
|
||||||
// messages have their content concatenated into a single message.
|
|
||||||
func mergeConsecutiveMessages(body []byte) ([]byte, error) {
|
|
||||||
request := &chatCompletionRequest{}
|
|
||||||
if err := json.Unmarshal(body, request); err != nil {
|
|
||||||
return body, fmt.Errorf("unable to unmarshal request for message merging: %v", err)
|
|
||||||
}
|
|
||||||
if len(request.Messages) <= 1 {
|
|
||||||
return body, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
merged := false
|
|
||||||
result := make([]chatMessage, 0, len(request.Messages))
|
|
||||||
for _, msg := range request.Messages {
|
|
||||||
if len(result) > 0 &&
|
|
||||||
result[len(result)-1].Role == msg.Role &&
|
|
||||||
(msg.Role == roleUser || msg.Role == roleAssistant) {
|
|
||||||
last := &result[len(result)-1]
|
|
||||||
last.Content = mergeMessageContent(last.Content, msg.Content)
|
|
||||||
merged = true
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
result = append(result, msg)
|
|
||||||
}
|
|
||||||
|
|
||||||
if !merged {
|
|
||||||
return body, nil
|
|
||||||
}
|
|
||||||
request.Messages = result
|
|
||||||
return json.Marshal(request)
|
|
||||||
}
|
|
||||||
|
|
||||||
// mergeMessageContent concatenates two message content values.
|
|
||||||
// If both are plain strings they are joined with a blank line.
|
|
||||||
// Otherwise both are converted to content-block arrays and concatenated.
|
|
||||||
func mergeMessageContent(prev, curr any) any {
|
|
||||||
prevStr, prevIsStr := prev.(string)
|
|
||||||
currStr, currIsStr := curr.(string)
|
|
||||||
if prevIsStr && currIsStr {
|
|
||||||
return prevStr + "\n\n" + currStr
|
|
||||||
}
|
|
||||||
prevParts := (&chatMessage{Content: prev}).ParseContent()
|
|
||||||
currParts := (&chatMessage{Content: curr}).ParseContent()
|
|
||||||
return append(prevParts, currParts...)
|
|
||||||
}
|
|
||||||
|
|
||||||
func ReplaceResponseBody(body []byte) error {
|
func ReplaceResponseBody(body []byte) error {
|
||||||
log.Debugf("response body: %s", string(body))
|
log.Debugf("response body: %s", string(body))
|
||||||
err := proxywasm.ReplaceHttpResponseBody(body)
|
err := proxywasm.ReplaceHttpResponseBody(body)
|
||||||
|
|||||||
@@ -8,131 +8,6 @@ import (
|
|||||||
"github.com/stretchr/testify/require"
|
"github.com/stretchr/testify/require"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestMergeConsecutiveMessages(t *testing.T) {
|
|
||||||
t.Run("no_consecutive_messages", func(t *testing.T) {
|
|
||||||
input := chatCompletionRequest{
|
|
||||||
Messages: []chatMessage{
|
|
||||||
{Role: "user", Content: "你好"},
|
|
||||||
{Role: "assistant", Content: "你好!"},
|
|
||||||
{Role: "user", Content: "再见"},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
body, err := json.Marshal(input)
|
|
||||||
require.NoError(t, err)
|
|
||||||
|
|
||||||
result, err := mergeConsecutiveMessages(body)
|
|
||||||
assert.NoError(t, err)
|
|
||||||
// No merging needed, returned body should be identical
|
|
||||||
assert.Equal(t, body, result)
|
|
||||||
})
|
|
||||||
|
|
||||||
t.Run("merges_consecutive_user_messages", func(t *testing.T) {
|
|
||||||
input := chatCompletionRequest{
|
|
||||||
Messages: []chatMessage{
|
|
||||||
{Role: "user", Content: "第一条"},
|
|
||||||
{Role: "user", Content: "第二条"},
|
|
||||||
{Role: "assistant", Content: "回复"},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
body, err := json.Marshal(input)
|
|
||||||
require.NoError(t, err)
|
|
||||||
|
|
||||||
result, err := mergeConsecutiveMessages(body)
|
|
||||||
assert.NoError(t, err)
|
|
||||||
|
|
||||||
var output chatCompletionRequest
|
|
||||||
require.NoError(t, json.Unmarshal(result, &output))
|
|
||||||
|
|
||||||
assert.Len(t, output.Messages, 2)
|
|
||||||
assert.Equal(t, "user", output.Messages[0].Role)
|
|
||||||
assert.Equal(t, "第一条\n\n第二条", output.Messages[0].Content)
|
|
||||||
assert.Equal(t, "assistant", output.Messages[1].Role)
|
|
||||||
})
|
|
||||||
|
|
||||||
t.Run("merges_consecutive_assistant_messages", func(t *testing.T) {
|
|
||||||
input := chatCompletionRequest{
|
|
||||||
Messages: []chatMessage{
|
|
||||||
{Role: "user", Content: "问题"},
|
|
||||||
{Role: "assistant", Content: "第一段"},
|
|
||||||
{Role: "assistant", Content: "第二段"},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
body, err := json.Marshal(input)
|
|
||||||
require.NoError(t, err)
|
|
||||||
|
|
||||||
result, err := mergeConsecutiveMessages(body)
|
|
||||||
assert.NoError(t, err)
|
|
||||||
|
|
||||||
var output chatCompletionRequest
|
|
||||||
require.NoError(t, json.Unmarshal(result, &output))
|
|
||||||
|
|
||||||
assert.Len(t, output.Messages, 2)
|
|
||||||
assert.Equal(t, "user", output.Messages[0].Role)
|
|
||||||
assert.Equal(t, "assistant", output.Messages[1].Role)
|
|
||||||
assert.Equal(t, "第一段\n\n第二段", output.Messages[1].Content)
|
|
||||||
})
|
|
||||||
|
|
||||||
t.Run("merges_multiple_consecutive_same_role", func(t *testing.T) {
|
|
||||||
input := chatCompletionRequest{
|
|
||||||
Messages: []chatMessage{
|
|
||||||
{Role: "user", Content: "A"},
|
|
||||||
{Role: "user", Content: "B"},
|
|
||||||
{Role: "user", Content: "C"},
|
|
||||||
{Role: "assistant", Content: "回复"},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
body, err := json.Marshal(input)
|
|
||||||
require.NoError(t, err)
|
|
||||||
|
|
||||||
result, err := mergeConsecutiveMessages(body)
|
|
||||||
assert.NoError(t, err)
|
|
||||||
|
|
||||||
var output chatCompletionRequest
|
|
||||||
require.NoError(t, json.Unmarshal(result, &output))
|
|
||||||
|
|
||||||
assert.Len(t, output.Messages, 2)
|
|
||||||
assert.Equal(t, "A\n\nB\n\nC", output.Messages[0].Content)
|
|
||||||
})
|
|
||||||
|
|
||||||
t.Run("system_messages_not_merged", func(t *testing.T) {
|
|
||||||
input := chatCompletionRequest{
|
|
||||||
Messages: []chatMessage{
|
|
||||||
{Role: "system", Content: "系统提示1"},
|
|
||||||
{Role: "system", Content: "系统提示2"},
|
|
||||||
{Role: "user", Content: "问题"},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
body, err := json.Marshal(input)
|
|
||||||
require.NoError(t, err)
|
|
||||||
|
|
||||||
result, err := mergeConsecutiveMessages(body)
|
|
||||||
assert.NoError(t, err)
|
|
||||||
// system messages are not merged, body unchanged
|
|
||||||
assert.Equal(t, body, result)
|
|
||||||
})
|
|
||||||
|
|
||||||
t.Run("single_message_unchanged", func(t *testing.T) {
|
|
||||||
input := chatCompletionRequest{
|
|
||||||
Messages: []chatMessage{
|
|
||||||
{Role: "user", Content: "只有一条"},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
body, err := json.Marshal(input)
|
|
||||||
require.NoError(t, err)
|
|
||||||
|
|
||||||
result, err := mergeConsecutiveMessages(body)
|
|
||||||
assert.NoError(t, err)
|
|
||||||
assert.Equal(t, body, result)
|
|
||||||
})
|
|
||||||
|
|
||||||
t.Run("invalid_json_body", func(t *testing.T) {
|
|
||||||
body := []byte(`invalid json`)
|
|
||||||
result, err := mergeConsecutiveMessages(body)
|
|
||||||
assert.Error(t, err)
|
|
||||||
assert.Equal(t, body, result)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestCleanupContextMessages(t *testing.T) {
|
func TestCleanupContextMessages(t *testing.T) {
|
||||||
t.Run("empty_cleanup_commands", func(t *testing.T) {
|
t.Run("empty_cleanup_commands", func(t *testing.T) {
|
||||||
body := []byte(`{"messages":[{"role":"user","content":"hello"}]}`)
|
body := []byte(`{"messages":[{"role":"user","content":"hello"}]}`)
|
||||||
|
|||||||
@@ -45,9 +45,7 @@ const (
|
|||||||
contextClaudeMarker = "isClaudeRequest"
|
contextClaudeMarker = "isClaudeRequest"
|
||||||
contextOpenAICompatibleMarker = "isOpenAICompatibleRequest"
|
contextOpenAICompatibleMarker = "isOpenAICompatibleRequest"
|
||||||
contextVertexRawMarker = "isVertexRawRequest"
|
contextVertexRawMarker = "isVertexRawRequest"
|
||||||
contextVertexStreamDoneMarker = "vertexStreamDoneSent"
|
|
||||||
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 路径
|
||||||
@@ -100,8 +98,6 @@ 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): "", // 空字符串表示保持原路径,不做路径转换
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -311,10 +307,6 @@ 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
|
||||||
}
|
}
|
||||||
@@ -395,108 +387,11 @@ 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, err := v.buildVertexImageGenerationRequest(request)
|
vertexRequest := v.buildVertexImageGenerationRequest(request)
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
return json.Marshal(vertexRequest)
|
return json.Marshal(vertexRequest)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (v *vertexProvider) onImageEditRequestBody(ctx wrapper.HttpContext, body []byte, headers http.Header) ([]byte, error) {
|
func (v *vertexProvider) buildVertexImageGenerationRequest(request *imageGenerationRequest) *vertexChatRequest {
|
||||||
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 {
|
||||||
@@ -507,12 +402,12 @@ func (v *vertexProvider) buildVertexImageRequest(prompt string, size string, out
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 解析尺寸参数
|
// 解析尺寸参数
|
||||||
aspectRatio, imageSize := v.parseImageSize(size)
|
aspectRatio, imageSize := v.parseImageSize(request.Size)
|
||||||
|
|
||||||
// 确定输出 MIME 类型
|
// 确定输出 MIME 类型
|
||||||
mimeType := "image/png"
|
mimeType := "image/png"
|
||||||
if outputFormat != "" {
|
if request.OutputFormat != "" {
|
||||||
switch outputFormat {
|
switch request.OutputFormat {
|
||||||
case "jpeg", "jpg":
|
case "jpeg", "jpg":
|
||||||
mimeType = "image/jpeg"
|
mimeType = "image/jpeg"
|
||||||
case "webp":
|
case "webp":
|
||||||
@@ -522,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{
|
vertexRequest := &vertexChatRequest{
|
||||||
Contents: []vertexChatContent{{
|
Contents: []vertexChatContent{{
|
||||||
Role: roleUser,
|
Role: roleUser,
|
||||||
Parts: parts,
|
Parts: []vertexPart{{
|
||||||
|
Text: request.Prompt,
|
||||||
|
}},
|
||||||
}},
|
}},
|
||||||
SafetySettings: safetySettings,
|
SafetySettings: safetySettings,
|
||||||
GenerationConfig: vertexChatGenerationConfig{
|
GenerationConfig: vertexChatGenerationConfig{
|
||||||
@@ -560,7 +440,7 @@ func (v *vertexProvider) buildVertexImageRequest(prompt string, size string, out
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
return vertexRequest, nil
|
return vertexRequest
|
||||||
}
|
}
|
||||||
|
|
||||||
// parseImageSize 解析 OpenAI 格式的尺寸字符串(如 "1024x1024")为 Vertex AI 的 aspectRatio 和 imageSize
|
// parseImageSize 解析 OpenAI 格式的尺寸字符串(如 "1024x1024")为 Vertex AI 的 aspectRatio 和 imageSize
|
||||||
@@ -622,46 +502,23 @@ func (v *vertexProvider) OnStreamingResponseBody(ctx wrapper.HttpContext, name A
|
|||||||
return v.claude.OnStreamingResponseBody(ctx, name, chunk, isLastChunk)
|
return v.claude.OnStreamingResponseBody(ctx, name, chunk, isLastChunk)
|
||||||
}
|
}
|
||||||
log.Infof("[vertexProvider] receive chunk body: %s", string(chunk))
|
log.Infof("[vertexProvider] receive chunk body: %s", string(chunk))
|
||||||
if len(chunk) == 0 && !isLastChunk {
|
|
||||||
return nil, nil
|
|
||||||
}
|
|
||||||
if name != ApiNameChatCompletion {
|
|
||||||
if isLastChunk {
|
if isLastChunk {
|
||||||
return []byte(ssePrefix + "[DONE]\n\n"), nil
|
return []byte(ssePrefix + "[DONE]\n\n"), nil
|
||||||
}
|
}
|
||||||
|
if len(chunk) == 0 {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
if name != ApiNameChatCompletion {
|
||||||
return chunk, nil
|
return chunk, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
responseBuilder := &strings.Builder{}
|
responseBuilder := &strings.Builder{}
|
||||||
// Flush a trailing event when upstream closes stream without a final blank line.
|
lines := strings.Split(string(chunk), "\n")
|
||||||
chunkForParsing := chunk
|
for _, data := range lines {
|
||||||
if isLastChunk {
|
if len(data) < 6 {
|
||||||
trailingNewLineCount := 0
|
// ignore blank line or wrong format
|
||||||
for i := len(chunkForParsing) - 1; i >= 0 && chunkForParsing[i] == '\n'; i-- {
|
|
||||||
trailingNewLineCount++
|
|
||||||
}
|
|
||||||
if trailingNewLineCount < 2 {
|
|
||||||
chunkForParsing = append([]byte(nil), chunk...)
|
|
||||||
for i := 0; i < 2-trailingNewLineCount; i++ {
|
|
||||||
chunkForParsing = append(chunkForParsing, '\n')
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
streamEvents := ExtractStreamingEvents(ctx, chunkForParsing)
|
|
||||||
doneSent, _ := ctx.GetContext(contextVertexStreamDoneMarker).(bool)
|
|
||||||
appendDone := isLastChunk && !doneSent
|
|
||||||
for _, event := range streamEvents {
|
|
||||||
data := event.Data
|
|
||||||
if data == "" {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if data == streamEndDataValue {
|
|
||||||
if !doneSent {
|
|
||||||
appendDone = true
|
|
||||||
doneSent = true
|
|
||||||
}
|
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
data = data[6:]
|
||||||
var vertexResp vertexChatResponse
|
var vertexResp vertexChatResponse
|
||||||
if err := json.Unmarshal([]byte(data), &vertexResp); err != nil {
|
if err := json.Unmarshal([]byte(data), &vertexResp); err != nil {
|
||||||
log.Errorf("unable to unmarshal vertex response: %v", err)
|
log.Errorf("unable to unmarshal vertex response: %v", err)
|
||||||
@@ -675,17 +532,7 @@ func (v *vertexProvider) OnStreamingResponseBody(ctx wrapper.HttpContext, name A
|
|||||||
}
|
}
|
||||||
v.appendResponse(responseBuilder, string(responseBody))
|
v.appendResponse(responseBuilder, string(responseBody))
|
||||||
}
|
}
|
||||||
if appendDone {
|
|
||||||
responseBuilder.WriteString(ssePrefix + "[DONE]\n\n")
|
|
||||||
doneSent = true
|
|
||||||
}
|
|
||||||
ctx.SetContext(contextVertexStreamDoneMarker, doneSent)
|
|
||||||
modifiedResponseChunk := responseBuilder.String()
|
modifiedResponseChunk := responseBuilder.String()
|
||||||
if modifiedResponseChunk == "" {
|
|
||||||
// Returning an empty payload prevents main.go from falling back to
|
|
||||||
// forwarding the original raw chunk, which may contain partial JSON.
|
|
||||||
return []byte(""), nil
|
|
||||||
}
|
|
||||||
log.Debugf("=== modified response chunk: %s", modifiedResponseChunk)
|
log.Debugf("=== modified response chunk: %s", modifiedResponseChunk)
|
||||||
return []byte(modifiedResponseChunk), nil
|
return []byte(modifiedResponseChunk), nil
|
||||||
}
|
}
|
||||||
@@ -706,7 +553,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, ApiNameImageEdit, ApiNameImageVariation:
|
case ApiNameImageGeneration:
|
||||||
return v.onImageGenerationResponseBody(ctx, body)
|
return v.onImageGenerationResponseBody(ctx, body)
|
||||||
default:
|
default:
|
||||||
return body, nil
|
return body, nil
|
||||||
@@ -937,7 +784,7 @@ func (v *vertexProvider) getRequestPath(apiName ApiName, modelId string, stream
|
|||||||
switch apiName {
|
switch apiName {
|
||||||
case ApiNameEmbeddings:
|
case ApiNameEmbeddings:
|
||||||
action = vertexEmbeddingAction
|
action = vertexEmbeddingAction
|
||||||
case ApiNameImageGeneration, ApiNameImageEdit, ApiNameImageVariation:
|
case ApiNameImageGeneration:
|
||||||
// 图片生成使用非流式端点,需要完整响应
|
// 图片生成使用非流式端点,需要完整响应
|
||||||
action = vertexChatCompletionAction
|
action = vertexChatCompletionAction
|
||||||
default:
|
default:
|
||||||
|
|||||||
@@ -1,11 +1,7 @@
|
|||||||
package test
|
package test
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"bytes"
|
|
||||||
"encoding/binary"
|
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"hash/crc32"
|
|
||||||
"strings"
|
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"github.com/higress-group/proxy-wasm-go-sdk/proxywasm/types"
|
"github.com/higress-group/proxy-wasm-go-sdk/proxywasm/types"
|
||||||
@@ -29,76 +25,6 @@ var basicBedrockConfig = func() json.RawMessage {
|
|||||||
return data
|
return data
|
||||||
}()
|
}()
|
||||||
|
|
||||||
// Test config: Bedrock original protocol config with AWS Access Key/Secret Key
|
|
||||||
var bedrockOriginalAkSkConfig = func() json.RawMessage {
|
|
||||||
data, _ := json.Marshal(map[string]interface{}{
|
|
||||||
"provider": map[string]interface{}{
|
|
||||||
"type": "bedrock",
|
|
||||||
"protocol": "original",
|
|
||||||
"awsAccessKey": "test-ak-for-unit-test",
|
|
||||||
"awsSecretKey": "test-sk-for-unit-test",
|
|
||||||
"awsRegion": "us-east-1",
|
|
||||||
},
|
|
||||||
})
|
|
||||||
return data
|
|
||||||
}()
|
|
||||||
|
|
||||||
// Test config: Bedrock original protocol config with api token
|
|
||||||
var bedrockOriginalApiTokenConfig = func() json.RawMessage {
|
|
||||||
data, _ := json.Marshal(map[string]interface{}{
|
|
||||||
"provider": map[string]interface{}{
|
|
||||||
"type": "bedrock",
|
|
||||||
"protocol": "original",
|
|
||||||
"awsRegion": "us-east-1",
|
|
||||||
"apiTokens": []string{
|
|
||||||
"test-token-for-unit-test",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
})
|
|
||||||
return data
|
|
||||||
}()
|
|
||||||
|
|
||||||
// Test config: Bedrock original protocol config with AWS Access Key/Secret Key and custom settings
|
|
||||||
var bedrockOriginalAkSkWithCustomSettingsConfig = func() json.RawMessage {
|
|
||||||
data, _ := json.Marshal(map[string]interface{}{
|
|
||||||
"provider": map[string]interface{}{
|
|
||||||
"type": "bedrock",
|
|
||||||
"protocol": "original",
|
|
||||||
"awsAccessKey": "test-ak-for-unit-test",
|
|
||||||
"awsSecretKey": "test-sk-for-unit-test",
|
|
||||||
"awsRegion": "us-east-1",
|
|
||||||
"customSettings": []map[string]interface{}{
|
|
||||||
{
|
|
||||||
"name": "foo",
|
|
||||||
"value": "\"bar\"",
|
|
||||||
"mode": "raw",
|
|
||||||
"overwrite": true,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
})
|
|
||||||
return data
|
|
||||||
}()
|
|
||||||
|
|
||||||
// Test config: Bedrock config with embeddings capability to verify generic SigV4 flow
|
|
||||||
var bedrockEmbeddingsCapabilityConfig = func() json.RawMessage {
|
|
||||||
data, _ := json.Marshal(map[string]interface{}{
|
|
||||||
"provider": map[string]interface{}{
|
|
||||||
"type": "bedrock",
|
|
||||||
"awsAccessKey": "test-ak-for-unit-test",
|
|
||||||
"awsSecretKey": "test-sk-for-unit-test",
|
|
||||||
"awsRegion": "us-east-1",
|
|
||||||
"capabilities": map[string]string{
|
|
||||||
"openai/v1/embeddings": "/model/amazon.titan-embed-text-v2:0/invoke",
|
|
||||||
},
|
|
||||||
"modelMapping": map[string]string{
|
|
||||||
"*": "amazon.titan-embed-text-v2:0",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
})
|
|
||||||
return data
|
|
||||||
}()
|
|
||||||
|
|
||||||
// Test config: Bedrock config with Bearer Token authentication
|
// Test config: Bedrock config with Bearer Token authentication
|
||||||
var bedrockApiTokenConfig = func() json.RawMessage {
|
var bedrockApiTokenConfig = func() json.RawMessage {
|
||||||
data, _ := json.Marshal(map[string]interface{}{
|
data, _ := json.Marshal(map[string]interface{}{
|
||||||
@@ -116,23 +42,6 @@ var bedrockApiTokenConfig = func() json.RawMessage {
|
|||||||
return data
|
return data
|
||||||
}()
|
}()
|
||||||
|
|
||||||
func bedrockApiTokenConfigWithCachePointPositions(positions map[string]bool) json.RawMessage {
|
|
||||||
data, _ := json.Marshal(map[string]interface{}{
|
|
||||||
"provider": map[string]interface{}{
|
|
||||||
"type": "bedrock",
|
|
||||||
"apiTokens": []string{
|
|
||||||
"test-token-for-unit-test",
|
|
||||||
},
|
|
||||||
"awsRegion": "us-east-1",
|
|
||||||
"modelMapping": map[string]string{
|
|
||||||
"*": "anthropic.claude-3-5-haiku-20241022-v1:0",
|
|
||||||
},
|
|
||||||
"bedrockPromptCachePointPositions": positions,
|
|
||||||
},
|
|
||||||
})
|
|
||||||
return data
|
|
||||||
}
|
|
||||||
|
|
||||||
// Test config: Bedrock config with multiple Bearer Tokens
|
// Test config: Bedrock config with multiple Bearer Tokens
|
||||||
var bedrockMultiTokenConfig = func() json.RawMessage {
|
var bedrockMultiTokenConfig = func() json.RawMessage {
|
||||||
data, _ := json.Marshal(map[string]interface{}{
|
data, _ := json.Marshal(map[string]interface{}{
|
||||||
@@ -390,372 +299,6 @@ func RunBedrockOnHttpRequestBodyTests(t *testing.T) {
|
|||||||
require.Contains(t, pathValue, "/converse", "Path should contain converse endpoint")
|
require.Contains(t, pathValue, "/converse", "Path should contain converse endpoint")
|
||||||
})
|
})
|
||||||
|
|
||||||
t.Run("bedrock request body prompt cache in_memory should inject system cache point only by default", func(t *testing.T) {
|
|
||||||
host, status := test.NewTestHost(bedrockApiTokenConfig)
|
|
||||||
defer host.Reset()
|
|
||||||
require.Equal(t, types.OnPluginStartStatusOK, status)
|
|
||||||
|
|
||||||
action := host.CallOnHttpRequestHeaders([][2]string{
|
|
||||||
{":authority", "example.com"},
|
|
||||||
{":path", "/v1/chat/completions"},
|
|
||||||
{":method", "POST"},
|
|
||||||
{"Content-Type", "application/json"},
|
|
||||||
})
|
|
||||||
require.Equal(t, types.HeaderStopIteration, action)
|
|
||||||
|
|
||||||
requestBody := `{
|
|
||||||
"model": "gpt-4",
|
|
||||||
"prompt_cache_retention": "in_memory",
|
|
||||||
"prompt_cache_key": "session-001",
|
|
||||||
"messages": [
|
|
||||||
{
|
|
||||||
"role": "system",
|
|
||||||
"content": "You are a helpful assistant."
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"role": "user",
|
|
||||||
"content": "Hello"
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}`
|
|
||||||
action = host.CallOnHttpRequestBody([]byte(requestBody))
|
|
||||||
require.Equal(t, types.ActionContinue, action)
|
|
||||||
|
|
||||||
processedBody := host.GetRequestBody()
|
|
||||||
require.NotNil(t, processedBody)
|
|
||||||
|
|
||||||
var bodyMap map[string]interface{}
|
|
||||||
err := json.Unmarshal(processedBody, &bodyMap)
|
|
||||||
require.NoError(t, err)
|
|
||||||
|
|
||||||
_, hasPromptCacheRetention := bodyMap["prompt_cache_retention"]
|
|
||||||
require.False(t, hasPromptCacheRetention, "prompt_cache_retention should not be forwarded to Bedrock")
|
|
||||||
_, hasPromptCacheKey := bodyMap["prompt_cache_key"]
|
|
||||||
require.False(t, hasPromptCacheKey, "prompt_cache_key should not be forwarded to Bedrock")
|
|
||||||
|
|
||||||
systemBlocks, ok := bodyMap["system"].([]interface{})
|
|
||||||
require.True(t, ok, "system should be an array")
|
|
||||||
require.Len(t, systemBlocks, 2, "system should contain text block and cachePoint block")
|
|
||||||
systemCachePointBlock := systemBlocks[len(systemBlocks)-1].(map[string]interface{})
|
|
||||||
systemCachePoint, ok := systemCachePointBlock["cachePoint"].(map[string]interface{})
|
|
||||||
require.True(t, ok, "system tail block should contain cachePoint")
|
|
||||||
require.Equal(t, "default", systemCachePoint["type"])
|
|
||||||
require.Equal(t, "5m", systemCachePoint["ttl"])
|
|
||||||
|
|
||||||
messages := bodyMap["messages"].([]interface{})
|
|
||||||
require.NotEmpty(t, messages, "messages should not be empty")
|
|
||||||
lastMessage := messages[len(messages)-1].(map[string]interface{})
|
|
||||||
lastMessageContent := lastMessage["content"].([]interface{})
|
|
||||||
require.Len(t, lastMessageContent, 1, "last message should keep original content only by default")
|
|
||||||
_, hasMessageCachePoint := lastMessageContent[0].(map[string]interface{})["cachePoint"]
|
|
||||||
require.False(t, hasMessageCachePoint, "last message should not include cachePoint by default")
|
|
||||||
})
|
|
||||||
|
|
||||||
t.Run("bedrock request body prompt cache 24h should map to 1h ttl on system cache point by default", func(t *testing.T) {
|
|
||||||
host, status := test.NewTestHost(bedrockApiTokenConfig)
|
|
||||||
defer host.Reset()
|
|
||||||
require.Equal(t, types.OnPluginStartStatusOK, status)
|
|
||||||
|
|
||||||
action := host.CallOnHttpRequestHeaders([][2]string{
|
|
||||||
{":authority", "example.com"},
|
|
||||||
{":path", "/v1/chat/completions"},
|
|
||||||
{":method", "POST"},
|
|
||||||
{"Content-Type", "application/json"},
|
|
||||||
})
|
|
||||||
require.Equal(t, types.HeaderStopIteration, action)
|
|
||||||
|
|
||||||
requestBody := `{
|
|
||||||
"model": "gpt-4",
|
|
||||||
"prompt_cache_retention": "24h",
|
|
||||||
"messages": [
|
|
||||||
{
|
|
||||||
"role": "system",
|
|
||||||
"content": "You are a helpful assistant."
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"role": "user",
|
|
||||||
"content": "Hello"
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}`
|
|
||||||
action = host.CallOnHttpRequestBody([]byte(requestBody))
|
|
||||||
require.Equal(t, types.ActionContinue, action)
|
|
||||||
|
|
||||||
processedBody := host.GetRequestBody()
|
|
||||||
require.NotNil(t, processedBody)
|
|
||||||
|
|
||||||
var bodyMap map[string]interface{}
|
|
||||||
err := json.Unmarshal(processedBody, &bodyMap)
|
|
||||||
require.NoError(t, err)
|
|
||||||
|
|
||||||
systemBlocks := bodyMap["system"].([]interface{})
|
|
||||||
systemCachePointBlock := systemBlocks[len(systemBlocks)-1].(map[string]interface{})
|
|
||||||
systemCachePoint := systemCachePointBlock["cachePoint"].(map[string]interface{})
|
|
||||||
require.Equal(t, "1h", systemCachePoint["ttl"])
|
|
||||||
|
|
||||||
messages := bodyMap["messages"].([]interface{})
|
|
||||||
lastMessage := messages[len(messages)-1].(map[string]interface{})
|
|
||||||
lastMessageContent := lastMessage["content"].([]interface{})
|
|
||||||
require.Len(t, lastMessageContent, 1, "last message should keep original content only by default")
|
|
||||||
_, hasMessageCachePoint := lastMessageContent[0].(map[string]interface{})["cachePoint"]
|
|
||||||
require.False(t, hasMessageCachePoint, "last message should not include cachePoint by default")
|
|
||||||
})
|
|
||||||
|
|
||||||
t.Run("bedrock request body prompt cache should insert cache points based on configured positions", func(t *testing.T) {
|
|
||||||
host, status := test.NewTestHost(bedrockApiTokenConfigWithCachePointPositions(map[string]bool{
|
|
||||||
"systemPrompt": true,
|
|
||||||
"lastUserMessage": true,
|
|
||||||
"lastMessage": false,
|
|
||||||
}))
|
|
||||||
defer host.Reset()
|
|
||||||
require.Equal(t, types.OnPluginStartStatusOK, status)
|
|
||||||
|
|
||||||
action := host.CallOnHttpRequestHeaders([][2]string{
|
|
||||||
{":authority", "example.com"},
|
|
||||||
{":path", "/v1/chat/completions"},
|
|
||||||
{":method", "POST"},
|
|
||||||
{"Content-Type", "application/json"},
|
|
||||||
})
|
|
||||||
require.Equal(t, types.HeaderStopIteration, action)
|
|
||||||
|
|
||||||
requestBody := `{
|
|
||||||
"model": "gpt-4",
|
|
||||||
"prompt_cache_retention": "in_memory",
|
|
||||||
"messages": [
|
|
||||||
{
|
|
||||||
"role": "system",
|
|
||||||
"content": "You are a helpful assistant."
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"role": "user",
|
|
||||||
"content": "Question from user"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"role": "assistant",
|
|
||||||
"content": "Previous assistant answer"
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}`
|
|
||||||
action = host.CallOnHttpRequestBody([]byte(requestBody))
|
|
||||||
require.Equal(t, types.ActionContinue, action)
|
|
||||||
|
|
||||||
processedBody := host.GetRequestBody()
|
|
||||||
require.NotNil(t, processedBody)
|
|
||||||
|
|
||||||
var bodyMap map[string]interface{}
|
|
||||||
err := json.Unmarshal(processedBody, &bodyMap)
|
|
||||||
require.NoError(t, err)
|
|
||||||
|
|
||||||
systemBlocks := bodyMap["system"].([]interface{})
|
|
||||||
require.Len(t, systemBlocks, 2, "system should include cachePoint due to systemPrompt=true")
|
|
||||||
systemCachePoint := systemBlocks[len(systemBlocks)-1].(map[string]interface{})["cachePoint"].(map[string]interface{})
|
|
||||||
require.Equal(t, "5m", systemCachePoint["ttl"])
|
|
||||||
|
|
||||||
messages := bodyMap["messages"].([]interface{})
|
|
||||||
require.Len(t, messages, 2, "system message should not be in messages array")
|
|
||||||
|
|
||||||
lastUserMessageContent := messages[0].(map[string]interface{})["content"].([]interface{})
|
|
||||||
require.Len(t, lastUserMessageContent, 2, "last user message should include one cachePoint")
|
|
||||||
lastUserMessageCachePoint := lastUserMessageContent[len(lastUserMessageContent)-1].(map[string]interface{})["cachePoint"].(map[string]interface{})
|
|
||||||
require.Equal(t, "5m", lastUserMessageCachePoint["ttl"])
|
|
||||||
|
|
||||||
lastMessageContent := messages[1].(map[string]interface{})["content"].([]interface{})
|
|
||||||
require.Len(t, lastMessageContent, 1, "last message should not include cachePoint when lastMessage=false")
|
|
||||||
})
|
|
||||||
|
|
||||||
t.Run("bedrock request body prompt cache should avoid duplicate insertion when lastUserMessage and lastMessage overlap", func(t *testing.T) {
|
|
||||||
host, status := test.NewTestHost(bedrockApiTokenConfigWithCachePointPositions(map[string]bool{
|
|
||||||
"systemPrompt": false,
|
|
||||||
"lastUserMessage": true,
|
|
||||||
"lastMessage": true,
|
|
||||||
}))
|
|
||||||
defer host.Reset()
|
|
||||||
require.Equal(t, types.OnPluginStartStatusOK, status)
|
|
||||||
|
|
||||||
action := host.CallOnHttpRequestHeaders([][2]string{
|
|
||||||
{":authority", "example.com"},
|
|
||||||
{":path", "/v1/chat/completions"},
|
|
||||||
{":method", "POST"},
|
|
||||||
{"Content-Type", "application/json"},
|
|
||||||
})
|
|
||||||
require.Equal(t, types.HeaderStopIteration, action)
|
|
||||||
|
|
||||||
requestBody := `{
|
|
||||||
"model": "gpt-4",
|
|
||||||
"prompt_cache_retention": "in_memory",
|
|
||||||
"messages": [
|
|
||||||
{
|
|
||||||
"role": "user",
|
|
||||||
"content": "Hello"
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}`
|
|
||||||
action = host.CallOnHttpRequestBody([]byte(requestBody))
|
|
||||||
require.Equal(t, types.ActionContinue, action)
|
|
||||||
|
|
||||||
processedBody := host.GetRequestBody()
|
|
||||||
require.NotNil(t, processedBody)
|
|
||||||
|
|
||||||
var bodyMap map[string]interface{}
|
|
||||||
err := json.Unmarshal(processedBody, &bodyMap)
|
|
||||||
require.NoError(t, err)
|
|
||||||
|
|
||||||
_, hasSystem := bodyMap["system"]
|
|
||||||
require.False(t, hasSystem, "system should not include cachePoint when systemPrompt=false and no system messages")
|
|
||||||
|
|
||||||
messages := bodyMap["messages"].([]interface{})
|
|
||||||
require.Len(t, messages, 1, "only one message should exist")
|
|
||||||
messageContent := messages[0].(map[string]interface{})["content"].([]interface{})
|
|
||||||
require.Len(t, messageContent, 2, "overlap positions should still insert only one cachePoint")
|
|
||||||
cachePoint := messageContent[len(messageContent)-1].(map[string]interface{})["cachePoint"].(map[string]interface{})
|
|
||||||
require.Equal(t, "5m", cachePoint["ttl"])
|
|
||||||
})
|
|
||||||
|
|
||||||
t.Run("bedrock request body with empty prompt cache retention should not inject cache points", func(t *testing.T) {
|
|
||||||
host, status := test.NewTestHost(bedrockApiTokenConfig)
|
|
||||||
defer host.Reset()
|
|
||||||
require.Equal(t, types.OnPluginStartStatusOK, status)
|
|
||||||
|
|
||||||
action := host.CallOnHttpRequestHeaders([][2]string{
|
|
||||||
{":authority", "example.com"},
|
|
||||||
{":path", "/v1/chat/completions"},
|
|
||||||
{":method", "POST"},
|
|
||||||
{"Content-Type", "application/json"},
|
|
||||||
})
|
|
||||||
require.Equal(t, types.HeaderStopIteration, action)
|
|
||||||
|
|
||||||
requestBody := `{
|
|
||||||
"model": "gpt-4",
|
|
||||||
"prompt_cache_retention": "",
|
|
||||||
"messages": [
|
|
||||||
{
|
|
||||||
"role": "system",
|
|
||||||
"content": "You are a helpful assistant."
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"role": "user",
|
|
||||||
"content": "Hello"
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}`
|
|
||||||
action = host.CallOnHttpRequestBody([]byte(requestBody))
|
|
||||||
require.Equal(t, types.ActionContinue, action)
|
|
||||||
|
|
||||||
processedBody := host.GetRequestBody()
|
|
||||||
require.NotNil(t, processedBody)
|
|
||||||
|
|
||||||
var bodyMap map[string]interface{}
|
|
||||||
err := json.Unmarshal(processedBody, &bodyMap)
|
|
||||||
require.NoError(t, err)
|
|
||||||
|
|
||||||
systemBlocks := bodyMap["system"].([]interface{})
|
|
||||||
require.Len(t, systemBlocks, 1, "system should only contain the original text block")
|
|
||||||
_, hasSystemCachePoint := systemBlocks[0].(map[string]interface{})["cachePoint"]
|
|
||||||
require.False(t, hasSystemCachePoint, "system block should not include cachePoint when retention is empty")
|
|
||||||
|
|
||||||
messages := bodyMap["messages"].([]interface{})
|
|
||||||
lastMessage := messages[len(messages)-1].(map[string]interface{})
|
|
||||||
lastMessageContent := lastMessage["content"].([]interface{})
|
|
||||||
require.Len(t, lastMessageContent, 1, "message should only contain original text block")
|
|
||||||
_, hasMessageCachePoint := lastMessageContent[0].(map[string]interface{})["cachePoint"]
|
|
||||||
require.False(t, hasMessageCachePoint, "message block should not include cachePoint when retention is empty")
|
|
||||||
})
|
|
||||||
|
|
||||||
t.Run("bedrock request body with unsupported prompt cache retention should not inject cache points", func(t *testing.T) {
|
|
||||||
host, status := test.NewTestHost(bedrockApiTokenConfig)
|
|
||||||
defer host.Reset()
|
|
||||||
require.Equal(t, types.OnPluginStartStatusOK, status)
|
|
||||||
|
|
||||||
action := host.CallOnHttpRequestHeaders([][2]string{
|
|
||||||
{":authority", "example.com"},
|
|
||||||
{":path", "/v1/chat/completions"},
|
|
||||||
{":method", "POST"},
|
|
||||||
{"Content-Type", "application/json"},
|
|
||||||
})
|
|
||||||
require.Equal(t, types.HeaderStopIteration, action)
|
|
||||||
|
|
||||||
requestBody := `{
|
|
||||||
"model": "gpt-4",
|
|
||||||
"prompt_cache_retention": "2h",
|
|
||||||
"messages": [
|
|
||||||
{
|
|
||||||
"role": "system",
|
|
||||||
"content": "You are a helpful assistant."
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"role": "user",
|
|
||||||
"content": "Hello"
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}`
|
|
||||||
action = host.CallOnHttpRequestBody([]byte(requestBody))
|
|
||||||
require.Equal(t, types.ActionContinue, action)
|
|
||||||
|
|
||||||
processedBody := host.GetRequestBody()
|
|
||||||
require.NotNil(t, processedBody)
|
|
||||||
|
|
||||||
var bodyMap map[string]interface{}
|
|
||||||
err := json.Unmarshal(processedBody, &bodyMap)
|
|
||||||
require.NoError(t, err)
|
|
||||||
|
|
||||||
systemBlocks := bodyMap["system"].([]interface{})
|
|
||||||
require.Len(t, systemBlocks, 1, "system should only contain the original text block")
|
|
||||||
_, hasSystemCachePoint := systemBlocks[0].(map[string]interface{})["cachePoint"]
|
|
||||||
require.False(t, hasSystemCachePoint, "system block should not include cachePoint when retention is unsupported")
|
|
||||||
|
|
||||||
messages := bodyMap["messages"].([]interface{})
|
|
||||||
lastMessage := messages[len(messages)-1].(map[string]interface{})
|
|
||||||
lastMessageContent := lastMessage["content"].([]interface{})
|
|
||||||
require.Len(t, lastMessageContent, 1, "message should only contain original text block")
|
|
||||||
_, hasMessageCachePoint := lastMessageContent[0].(map[string]interface{})["cachePoint"]
|
|
||||||
require.False(t, hasMessageCachePoint, "message block should not include cachePoint when retention is unsupported")
|
|
||||||
})
|
|
||||||
|
|
||||||
t.Run("bedrock request body without system should not inject cache point by default", func(t *testing.T) {
|
|
||||||
host, status := test.NewTestHost(bedrockApiTokenConfig)
|
|
||||||
defer host.Reset()
|
|
||||||
require.Equal(t, types.OnPluginStartStatusOK, status)
|
|
||||||
|
|
||||||
action := host.CallOnHttpRequestHeaders([][2]string{
|
|
||||||
{":authority", "example.com"},
|
|
||||||
{":path", "/v1/chat/completions"},
|
|
||||||
{":method", "POST"},
|
|
||||||
{"Content-Type", "application/json"},
|
|
||||||
})
|
|
||||||
require.Equal(t, types.HeaderStopIteration, action)
|
|
||||||
|
|
||||||
requestBody := `{
|
|
||||||
"model": "gpt-4",
|
|
||||||
"prompt_cache_retention": "in_memory",
|
|
||||||
"messages": [
|
|
||||||
{
|
|
||||||
"role": "user",
|
|
||||||
"content": "Hello"
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}`
|
|
||||||
action = host.CallOnHttpRequestBody([]byte(requestBody))
|
|
||||||
require.Equal(t, types.ActionContinue, action)
|
|
||||||
|
|
||||||
processedBody := host.GetRequestBody()
|
|
||||||
require.NotNil(t, processedBody)
|
|
||||||
|
|
||||||
var bodyMap map[string]interface{}
|
|
||||||
err := json.Unmarshal(processedBody, &bodyMap)
|
|
||||||
require.NoError(t, err)
|
|
||||||
|
|
||||||
_, hasSystem := bodyMap["system"]
|
|
||||||
require.False(t, hasSystem, "system should be omitted when original request has no system prompts")
|
|
||||||
|
|
||||||
messages := bodyMap["messages"].([]interface{})
|
|
||||||
require.Len(t, messages, 1, "messages should keep original one user message")
|
|
||||||
lastMessage := messages[0].(map[string]interface{})
|
|
||||||
lastMessageContent := lastMessage["content"].([]interface{})
|
|
||||||
require.Len(t, lastMessageContent, 1, "message should keep original text block only by default")
|
|
||||||
_, hasMessageCachePoint := lastMessageContent[0].(map[string]interface{})["cachePoint"]
|
|
||||||
require.False(t, hasMessageCachePoint, "message should not include cachePoint by default")
|
|
||||||
})
|
|
||||||
|
|
||||||
// Test Bedrock request body processing with AWS Signature V4 authentication
|
// Test Bedrock request body processing with AWS Signature V4 authentication
|
||||||
t.Run("bedrock chat completion request body with ak/sk", func(t *testing.T) {
|
t.Run("bedrock chat completion request body with ak/sk", func(t *testing.T) {
|
||||||
host, status := test.NewTestHost(basicBedrockConfig)
|
host, status := test.NewTestHost(basicBedrockConfig)
|
||||||
@@ -809,169 +352,6 @@ func RunBedrockOnHttpRequestBodyTests(t *testing.T) {
|
|||||||
require.Contains(t, pathValue, "/converse", "Path should contain converse endpoint")
|
require.Contains(t, pathValue, "/converse", "Path should contain converse endpoint")
|
||||||
})
|
})
|
||||||
|
|
||||||
// Test Bedrock generic request body processing with AWS Signature V4 authentication
|
|
||||||
t.Run("bedrock embeddings request body with ak/sk should use sigv4", func(t *testing.T) {
|
|
||||||
host, status := test.NewTestHost(bedrockEmbeddingsCapabilityConfig)
|
|
||||||
defer host.Reset()
|
|
||||||
require.Equal(t, types.OnPluginStartStatusOK, status)
|
|
||||||
|
|
||||||
action := host.CallOnHttpRequestHeaders([][2]string{
|
|
||||||
{":authority", "example.com"},
|
|
||||||
{":path", "/v1/embeddings"},
|
|
||||||
{":method", "POST"},
|
|
||||||
{"Content-Type", "application/json"},
|
|
||||||
})
|
|
||||||
require.Equal(t, types.HeaderStopIteration, action)
|
|
||||||
|
|
||||||
requestBody := `{
|
|
||||||
"model": "text-embedding-3-small",
|
|
||||||
"input": "Hello from embeddings"
|
|
||||||
}`
|
|
||||||
action = host.CallOnHttpRequestBody([]byte(requestBody))
|
|
||||||
require.Equal(t, types.ActionContinue, action)
|
|
||||||
|
|
||||||
requestHeaders := host.GetRequestHeaders()
|
|
||||||
require.NotNil(t, requestHeaders)
|
|
||||||
|
|
||||||
authValue, hasAuth := test.GetHeaderValue(requestHeaders, "Authorization")
|
|
||||||
require.True(t, hasAuth, "Authorization header should exist")
|
|
||||||
require.Contains(t, authValue, "AWS4-HMAC-SHA256", "Authorization should use AWS4-HMAC-SHA256 signature")
|
|
||||||
require.Contains(t, authValue, "Credential=", "Authorization should contain Credential")
|
|
||||||
require.Contains(t, authValue, "Signature=", "Authorization should contain Signature")
|
|
||||||
|
|
||||||
dateValue, hasDate := test.GetHeaderValue(requestHeaders, "X-Amz-Date")
|
|
||||||
require.True(t, hasDate, "X-Amz-Date header should exist for AWS Signature V4")
|
|
||||||
require.NotEmpty(t, dateValue, "X-Amz-Date should not be empty")
|
|
||||||
})
|
|
||||||
|
|
||||||
// Test Bedrock original converse-stream path with AWS Signature V4 authentication
|
|
||||||
t.Run("bedrock original converse-stream with ak/sk should use sigv4", func(t *testing.T) {
|
|
||||||
host, status := test.NewTestHost(bedrockOriginalAkSkConfig)
|
|
||||||
defer host.Reset()
|
|
||||||
require.Equal(t, types.OnPluginStartStatusOK, status)
|
|
||||||
|
|
||||||
originalPath := "/model/anthropic.claude-3-5-haiku-20241022-v1%3A0/converse-stream"
|
|
||||||
action := host.CallOnHttpRequestHeaders([][2]string{
|
|
||||||
{":authority", "example.com"},
|
|
||||||
{":path", originalPath},
|
|
||||||
{":method", "POST"},
|
|
||||||
{"Content-Type", "application/json"},
|
|
||||||
})
|
|
||||||
require.Equal(t, types.HeaderStopIteration, action)
|
|
||||||
|
|
||||||
requestBody := `{
|
|
||||||
"messages": [
|
|
||||||
{
|
|
||||||
"role": "user",
|
|
||||||
"content": [{"text": "Hello from original bedrock path"}]
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"inferenceConfig": {
|
|
||||||
"maxTokens": 64
|
|
||||||
}
|
|
||||||
}`
|
|
||||||
action = host.CallOnHttpRequestBody([]byte(requestBody))
|
|
||||||
require.Equal(t, types.ActionContinue, action)
|
|
||||||
|
|
||||||
requestHeaders := host.GetRequestHeaders()
|
|
||||||
require.NotNil(t, requestHeaders)
|
|
||||||
|
|
||||||
authValue, hasAuth := test.GetHeaderValue(requestHeaders, "Authorization")
|
|
||||||
require.True(t, hasAuth, "Authorization header should exist")
|
|
||||||
require.Contains(t, authValue, "AWS4-HMAC-SHA256", "Authorization should use AWS4-HMAC-SHA256 signature")
|
|
||||||
require.Contains(t, authValue, "Credential=", "Authorization should contain Credential")
|
|
||||||
require.Contains(t, authValue, "Signature=", "Authorization should contain Signature")
|
|
||||||
|
|
||||||
dateValue, hasDate := test.GetHeaderValue(requestHeaders, "X-Amz-Date")
|
|
||||||
require.True(t, hasDate, "X-Amz-Date header should exist for AWS Signature V4")
|
|
||||||
require.NotEmpty(t, dateValue, "X-Amz-Date should not be empty")
|
|
||||||
|
|
||||||
pathValue, hasPath := test.GetHeaderValue(requestHeaders, ":path")
|
|
||||||
require.True(t, hasPath, "Path header should exist")
|
|
||||||
require.Equal(t, originalPath, pathValue, "Original Bedrock path should be kept unchanged")
|
|
||||||
})
|
|
||||||
|
|
||||||
// Test Bedrock original converse-stream path with Bearer Token authentication
|
|
||||||
t.Run("bedrock original converse-stream with api token should pass bearer auth", func(t *testing.T) {
|
|
||||||
host, status := test.NewTestHost(bedrockOriginalApiTokenConfig)
|
|
||||||
defer host.Reset()
|
|
||||||
require.Equal(t, types.OnPluginStartStatusOK, status)
|
|
||||||
|
|
||||||
originalPath := "/model/anthropic.claude-3-5-haiku-20241022-v1%3A0/converse-stream"
|
|
||||||
action := host.CallOnHttpRequestHeaders([][2]string{
|
|
||||||
{":authority", "example.com"},
|
|
||||||
{":path", originalPath},
|
|
||||||
{":method", "POST"},
|
|
||||||
{"Content-Type", "application/json"},
|
|
||||||
})
|
|
||||||
require.Equal(t, types.HeaderStopIteration, action)
|
|
||||||
|
|
||||||
requestBody := `{
|
|
||||||
"messages": [
|
|
||||||
{
|
|
||||||
"role": "user",
|
|
||||||
"content": [{"text": "Hello from original bedrock path"}]
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}`
|
|
||||||
action = host.CallOnHttpRequestBody([]byte(requestBody))
|
|
||||||
require.Equal(t, types.ActionContinue, action)
|
|
||||||
|
|
||||||
requestHeaders := host.GetRequestHeaders()
|
|
||||||
require.NotNil(t, requestHeaders)
|
|
||||||
|
|
||||||
authValue, hasAuth := test.GetHeaderValue(requestHeaders, "Authorization")
|
|
||||||
require.True(t, hasAuth, "Authorization header should exist")
|
|
||||||
require.Contains(t, authValue, "Bearer ", "Authorization should use Bearer token")
|
|
||||||
require.Contains(t, authValue, "test-token-for-unit-test", "Authorization should contain configured token")
|
|
||||||
|
|
||||||
_, hasDate := test.GetHeaderValue(requestHeaders, "X-Amz-Date")
|
|
||||||
require.False(t, hasDate, "X-Amz-Date should not be set in Bearer token mode")
|
|
||||||
|
|
||||||
pathValue, hasPath := test.GetHeaderValue(requestHeaders, ":path")
|
|
||||||
require.True(t, hasPath, "Path header should exist")
|
|
||||||
require.Equal(t, originalPath, pathValue, "Original Bedrock path should be kept unchanged")
|
|
||||||
})
|
|
||||||
|
|
||||||
// Test Bedrock original converse-stream path keeps signed body consistent with custom settings
|
|
||||||
t.Run("bedrock original converse-stream with custom settings should replace body before forwarding", func(t *testing.T) {
|
|
||||||
host, status := test.NewTestHost(bedrockOriginalAkSkWithCustomSettingsConfig)
|
|
||||||
defer host.Reset()
|
|
||||||
require.Equal(t, types.OnPluginStartStatusOK, status)
|
|
||||||
|
|
||||||
originalPath := "/model/amazon.nova-2-lite-v1:0/converse-stream"
|
|
||||||
action := host.CallOnHttpRequestHeaders([][2]string{
|
|
||||||
{":authority", "example.com"},
|
|
||||||
{":path", originalPath},
|
|
||||||
{":method", "POST"},
|
|
||||||
{"Content-Type", "application/json"},
|
|
||||||
})
|
|
||||||
require.Equal(t, types.HeaderStopIteration, action)
|
|
||||||
|
|
||||||
requestBody := `{
|
|
||||||
"messages": [
|
|
||||||
{
|
|
||||||
"role": "user",
|
|
||||||
"content": [{"text": "Hello"}]
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}`
|
|
||||||
action = host.CallOnHttpRequestBody([]byte(requestBody))
|
|
||||||
require.Equal(t, types.ActionContinue, action)
|
|
||||||
|
|
||||||
processedBody := host.GetRequestBody()
|
|
||||||
require.NotNil(t, processedBody)
|
|
||||||
|
|
||||||
var bodyMap map[string]interface{}
|
|
||||||
err := json.Unmarshal(processedBody, &bodyMap)
|
|
||||||
require.NoError(t, err)
|
|
||||||
require.Equal(t, "\"bar\"", bodyMap["foo"], "Custom settings should be applied to forwarded body")
|
|
||||||
|
|
||||||
authValue, hasAuth := test.GetHeaderValue(host.GetRequestHeaders(), "Authorization")
|
|
||||||
require.True(t, hasAuth, "Authorization header should exist")
|
|
||||||
require.Contains(t, authValue, "AWS4-HMAC-SHA256", "Authorization should use AWS4-HMAC-SHA256 signature")
|
|
||||||
})
|
|
||||||
|
|
||||||
// Test Bedrock streaming request
|
// Test Bedrock streaming request
|
||||||
t.Run("bedrock streaming request", func(t *testing.T) {
|
t.Run("bedrock streaming request", func(t *testing.T) {
|
||||||
host, status := test.NewTestHost(bedrockApiTokenConfig)
|
host, status := test.NewTestHost(bedrockApiTokenConfig)
|
||||||
@@ -1298,9 +678,7 @@ func RunBedrockOnHttpResponseBodyTests(t *testing.T) {
|
|||||||
"usage": {
|
"usage": {
|
||||||
"inputTokens": 10,
|
"inputTokens": 10,
|
||||||
"outputTokens": 15,
|
"outputTokens": 15,
|
||||||
"totalTokens": 25,
|
"totalTokens": 25
|
||||||
"cacheReadInputTokens": 6,
|
|
||||||
"cacheWriteInputTokens": 12
|
|
||||||
}
|
}
|
||||||
}`
|
}`
|
||||||
|
|
||||||
@@ -1324,176 +702,6 @@ func RunBedrockOnHttpResponseBodyTests(t *testing.T) {
|
|||||||
usage, exists := responseMap["usage"]
|
usage, exists := responseMap["usage"]
|
||||||
require.True(t, exists, "Usage should exist in response body")
|
require.True(t, exists, "Usage should exist in response body")
|
||||||
require.NotNil(t, usage, "Usage should not be nil")
|
require.NotNil(t, usage, "Usage should not be nil")
|
||||||
usageMap := usage.(map[string]interface{})
|
|
||||||
promptTokensDetails, hasPromptTokensDetails := usageMap["prompt_tokens_details"].(map[string]interface{})
|
|
||||||
require.True(t, hasPromptTokensDetails, "prompt_tokens_details should exist when cacheReadInputTokens is present")
|
|
||||||
require.Equal(t, float64(6), promptTokensDetails["cached_tokens"], "cached_tokens should map from cacheReadInputTokens")
|
|
||||||
})
|
|
||||||
|
|
||||||
t.Run("bedrock response body with zero cache read tokens should omit prompt_tokens_details", func(t *testing.T) {
|
|
||||||
host, status := test.NewTestHost(bedrockApiTokenConfig)
|
|
||||||
defer host.Reset()
|
|
||||||
require.Equal(t, types.OnPluginStartStatusOK, status)
|
|
||||||
|
|
||||||
action := host.CallOnHttpRequestHeaders([][2]string{
|
|
||||||
{":authority", "example.com"},
|
|
||||||
{":path", "/v1/chat/completions"},
|
|
||||||
{":method", "POST"},
|
|
||||||
{"Content-Type", "application/json"},
|
|
||||||
})
|
|
||||||
require.Equal(t, types.HeaderStopIteration, action)
|
|
||||||
|
|
||||||
requestBody := `{
|
|
||||||
"model": "gpt-4",
|
|
||||||
"messages": [
|
|
||||||
{
|
|
||||||
"role": "user",
|
|
||||||
"content": "Hello"
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}`
|
|
||||||
action = host.CallOnHttpRequestBody([]byte(requestBody))
|
|
||||||
require.Equal(t, types.ActionContinue, action)
|
|
||||||
|
|
||||||
host.SetProperty([]string{"response", "code_details"}, []byte("via_upstream"))
|
|
||||||
|
|
||||||
action = host.CallOnHttpResponseHeaders([][2]string{
|
|
||||||
{":status", "200"},
|
|
||||||
{"Content-Type", "application/json"},
|
|
||||||
})
|
|
||||||
require.Equal(t, types.ActionContinue, action)
|
|
||||||
|
|
||||||
responseBody := `{
|
|
||||||
"output": {
|
|
||||||
"message": {
|
|
||||||
"role": "assistant",
|
|
||||||
"content": [
|
|
||||||
{
|
|
||||||
"text": "Hello! How can I help you today?"
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"stopReason": "end_turn",
|
|
||||||
"usage": {
|
|
||||||
"inputTokens": 10,
|
|
||||||
"outputTokens": 15,
|
|
||||||
"totalTokens": 25,
|
|
||||||
"cacheReadInputTokens": 0
|
|
||||||
}
|
|
||||||
}`
|
|
||||||
|
|
||||||
action = host.CallOnHttpResponseBody([]byte(responseBody))
|
|
||||||
require.Equal(t, types.ActionContinue, action)
|
|
||||||
|
|
||||||
transformedResponseBody := host.GetResponseBody()
|
|
||||||
require.NotNil(t, transformedResponseBody)
|
|
||||||
|
|
||||||
var responseMap map[string]interface{}
|
|
||||||
err := json.Unmarshal(transformedResponseBody, &responseMap)
|
|
||||||
require.NoError(t, err)
|
|
||||||
|
|
||||||
usageMap := responseMap["usage"].(map[string]interface{})
|
|
||||||
_, hasPromptTokensDetails := usageMap["prompt_tokens_details"]
|
|
||||||
require.False(t, hasPromptTokensDetails, "prompt_tokens_details should be omitted when cacheReadInputTokens is zero")
|
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
func RunBedrockOnStreamingResponseBodyTests(t *testing.T) {
|
|
||||||
test.RunTest(t, func(t *testing.T) {
|
|
||||||
t.Run("bedrock streaming usage should map cached_tokens", func(t *testing.T) {
|
|
||||||
host, status := test.NewTestHost(bedrockApiTokenConfig)
|
|
||||||
defer host.Reset()
|
|
||||||
require.Equal(t, types.OnPluginStartStatusOK, status)
|
|
||||||
|
|
||||||
action := host.CallOnHttpRequestHeaders([][2]string{
|
|
||||||
{":authority", "example.com"},
|
|
||||||
{":path", "/v1/chat/completions"},
|
|
||||||
{":method", "POST"},
|
|
||||||
{"Content-Type", "application/json"},
|
|
||||||
})
|
|
||||||
require.Equal(t, types.HeaderStopIteration, action)
|
|
||||||
|
|
||||||
requestBody := `{
|
|
||||||
"model": "gpt-4",
|
|
||||||
"messages": [
|
|
||||||
{
|
|
||||||
"role": "user",
|
|
||||||
"content": "Hello"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"stream": true
|
|
||||||
}`
|
|
||||||
action = host.CallOnHttpRequestBody([]byte(requestBody))
|
|
||||||
require.Equal(t, types.ActionContinue, action)
|
|
||||||
|
|
||||||
host.SetProperty([]string{"response", "code_details"}, []byte("via_upstream"))
|
|
||||||
|
|
||||||
action = host.CallOnHttpResponseHeaders([][2]string{
|
|
||||||
{":status", "200"},
|
|
||||||
{"Content-Type", "application/vnd.amazon.eventstream"},
|
|
||||||
})
|
|
||||||
require.Equal(t, types.ActionContinue, action)
|
|
||||||
|
|
||||||
streamingChunk := buildBedrockEventStreamMessage(t, map[string]interface{}{
|
|
||||||
"usage": map[string]interface{}{
|
|
||||||
"inputTokens": 10,
|
|
||||||
"outputTokens": 2,
|
|
||||||
"totalTokens": 12,
|
|
||||||
"cacheReadInputTokens": 7,
|
|
||||||
"cacheWriteInputTokens": 3,
|
|
||||||
},
|
|
||||||
})
|
|
||||||
action = host.CallOnHttpStreamingResponseBody(streamingChunk, true)
|
|
||||||
require.Equal(t, types.ActionContinue, action)
|
|
||||||
|
|
||||||
transformedResponseBody := host.GetResponseBody()
|
|
||||||
require.NotNil(t, transformedResponseBody)
|
|
||||||
|
|
||||||
var dataPayload string
|
|
||||||
for _, line := range strings.Split(string(transformedResponseBody), "\n") {
|
|
||||||
if strings.HasPrefix(line, "data: ") && line != "data: [DONE]" {
|
|
||||||
dataPayload = strings.TrimPrefix(line, "data: ")
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
require.NotEmpty(t, dataPayload, "should have at least one SSE data payload")
|
|
||||||
|
|
||||||
var responseMap map[string]interface{}
|
|
||||||
err := json.Unmarshal([]byte(dataPayload), &responseMap)
|
|
||||||
require.NoError(t, err)
|
|
||||||
usageMap := responseMap["usage"].(map[string]interface{})
|
|
||||||
promptTokensDetails := usageMap["prompt_tokens_details"].(map[string]interface{})
|
|
||||||
require.Equal(t, float64(7), promptTokensDetails["cached_tokens"], "cached_tokens should map from cacheReadInputTokens in streaming usage event")
|
|
||||||
})
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
func buildBedrockEventStreamMessage(t *testing.T, payload map[string]interface{}) []byte {
|
|
||||||
payloadBytes, err := json.Marshal(payload)
|
|
||||||
require.NoError(t, err)
|
|
||||||
|
|
||||||
totalLength := uint32(16 + len(payloadBytes))
|
|
||||||
headersLength := uint32(0)
|
|
||||||
|
|
||||||
var message bytes.Buffer
|
|
||||||
prelude := make([]byte, 8)
|
|
||||||
binary.BigEndian.PutUint32(prelude[0:4], totalLength)
|
|
||||||
binary.BigEndian.PutUint32(prelude[4:8], headersLength)
|
|
||||||
message.Write(prelude)
|
|
||||||
|
|
||||||
preludeCRC := crc32.ChecksumIEEE(prelude)
|
|
||||||
preludeCRCBytes := make([]byte, 4)
|
|
||||||
binary.BigEndian.PutUint32(preludeCRCBytes, preludeCRC)
|
|
||||||
message.Write(preludeCRCBytes)
|
|
||||||
|
|
||||||
message.Write(payloadBytes)
|
|
||||||
|
|
||||||
messageCRC := crc32.ChecksumIEEE(message.Bytes())
|
|
||||||
messageCRCBytes := make([]byte, 4)
|
|
||||||
binary.BigEndian.PutUint32(messageCRCBytes, messageCRC)
|
|
||||||
message.Write(messageCRCBytes)
|
|
||||||
|
|
||||||
return message.Bytes()
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,9 +1,7 @@
|
|||||||
package test
|
package test
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"bytes"
|
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"mime/multipart"
|
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
@@ -691,7 +689,7 @@ func RunVertexOpenAICompatibleModeOnHttpRequestBodyTests(t *testing.T) {
|
|||||||
|
|
||||||
func RunVertexExpressModeOnStreamingResponseBodyTests(t *testing.T) {
|
func RunVertexExpressModeOnStreamingResponseBodyTests(t *testing.T) {
|
||||||
test.RunTest(t, func(t *testing.T) {
|
test.RunTest(t, func(t *testing.T) {
|
||||||
// 测试 Vertex Express Mode 流式响应处理:最后一个 chunk 不应丢失
|
// 测试 Vertex Express Mode 流式响应处理
|
||||||
t.Run("vertex express mode streaming response body", func(t *testing.T) {
|
t.Run("vertex express mode streaming response body", func(t *testing.T) {
|
||||||
host, status := test.NewTestHost(vertexExpressModeConfig)
|
host, status := test.NewTestHost(vertexExpressModeConfig)
|
||||||
defer host.Reset()
|
defer host.Reset()
|
||||||
@@ -709,9 +707,6 @@ func RunVertexExpressModeOnStreamingResponseBodyTests(t *testing.T) {
|
|||||||
requestBody := `{"model":"gemini-2.5-flash","messages":[{"role":"user","content":"test"}],"stream":true}`
|
requestBody := `{"model":"gemini-2.5-flash","messages":[{"role":"user","content":"test"}],"stream":true}`
|
||||||
host.CallOnHttpRequestBody([]byte(requestBody))
|
host.CallOnHttpRequestBody([]byte(requestBody))
|
||||||
|
|
||||||
// 设置响应属性,确保IsResponseFromUpstream()返回true
|
|
||||||
host.SetProperty([]string{"response", "code_details"}, []byte("via_upstream"))
|
|
||||||
|
|
||||||
// 设置流式响应头
|
// 设置流式响应头
|
||||||
responseHeaders := [][2]string{
|
responseHeaders := [][2]string{
|
||||||
{":status", "200"},
|
{":status", "200"},
|
||||||
@@ -720,8 +715,8 @@ func RunVertexExpressModeOnStreamingResponseBodyTests(t *testing.T) {
|
|||||||
host.CallOnHttpResponseHeaders(responseHeaders)
|
host.CallOnHttpResponseHeaders(responseHeaders)
|
||||||
|
|
||||||
// 模拟流式响应体
|
// 模拟流式响应体
|
||||||
chunk1 := "data: {\"candidates\":[{\"content\":{\"parts\":[{\"text\":\"Hello\"}],\"role\":\"model\"},\"finishReason\":\"\",\"index\":0}],\"usageMetadata\":{\"promptTokenCount\":9,\"candidatesTokenCount\":5,\"totalTokenCount\":14}}\n\n"
|
chunk1 := `data: {"candidates":[{"content":{"parts":[{"text":"Hello"}],"role":"model"},"finishReason":"","index":0}],"usageMetadata":{"promptTokenCount":9,"candidatesTokenCount":5,"totalTokenCount":14}}`
|
||||||
chunk2 := "data: {\"candidates\":[{\"content\":{\"parts\":[{\"text\":\"Hello! How can I help you today?\"}],\"role\":\"model\"},\"finishReason\":\"STOP\",\"index\":0}],\"usageMetadata\":{\"promptTokenCount\":9,\"candidatesTokenCount\":12,\"totalTokenCount\":21}}\n\n"
|
chunk2 := `data: {"candidates":[{"content":{"parts":[{"text":"Hello! How can I help you today?"}],"role":"model"},"finishReason":"STOP","index":0}],"usageMetadata":{"promptTokenCount":9,"candidatesTokenCount":12,"totalTokenCount":21}}`
|
||||||
|
|
||||||
// 处理流式响应体
|
// 处理流式响应体
|
||||||
action1 := host.CallOnHttpStreamingResponseBody([]byte(chunk1), false)
|
action1 := host.CallOnHttpStreamingResponseBody([]byte(chunk1), false)
|
||||||
@@ -730,194 +725,16 @@ func RunVertexExpressModeOnStreamingResponseBodyTests(t *testing.T) {
|
|||||||
action2 := host.CallOnHttpStreamingResponseBody([]byte(chunk2), true)
|
action2 := host.CallOnHttpStreamingResponseBody([]byte(chunk2), true)
|
||||||
require.Equal(t, types.ActionContinue, action2)
|
require.Equal(t, types.ActionContinue, action2)
|
||||||
|
|
||||||
// 验证最后一个 chunk 的内容不会被 [DONE] 覆盖
|
// 验证流式响应处理
|
||||||
transformedResponseBody := host.GetResponseBody()
|
debugLogs := host.GetDebugLogs()
|
||||||
require.NotNil(t, transformedResponseBody)
|
hasStreamingLogs := false
|
||||||
responseStr := string(transformedResponseBody)
|
for _, log := range debugLogs {
|
||||||
require.Contains(t, responseStr, "Hello! How can I help you today?", "last chunk content should be preserved")
|
if strings.Contains(log, "streaming") || strings.Contains(log, "chunk") || strings.Contains(log, "vertex") {
|
||||||
require.Contains(t, responseStr, "data: [DONE]", "stream should end with [DONE]")
|
hasStreamingLogs = true
|
||||||
})
|
|
||||||
|
|
||||||
// 测试 Vertex Express Mode 流式响应处理:单个 SSE 事件被拆包时可正确重组
|
|
||||||
t.Run("vertex express mode streaming response body with split sse event", 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/chat/completions"},
|
|
||||||
{":method", "POST"},
|
|
||||||
{"Content-Type", "application/json"},
|
|
||||||
})
|
|
||||||
|
|
||||||
requestBody := `{"model":"gemini-2.5-flash","messages":[{"role":"user","content":"test"}],"stream":true}`
|
|
||||||
host.CallOnHttpRequestBody([]byte(requestBody))
|
|
||||||
|
|
||||||
// 设置响应属性,确保IsResponseFromUpstream()返回true
|
|
||||||
host.SetProperty([]string{"response", "code_details"}, []byte("via_upstream"))
|
|
||||||
|
|
||||||
responseHeaders := [][2]string{
|
|
||||||
{":status", "200"},
|
|
||||||
{"Content-Type", "text/event-stream"},
|
|
||||||
}
|
|
||||||
host.CallOnHttpResponseHeaders(responseHeaders)
|
|
||||||
|
|
||||||
fullEvent := "data: {\"candidates\":[{\"content\":{\"parts\":[{\"text\":\"split chunk\"}],\"role\":\"model\"},\"finishReason\":\"STOP\",\"index\":0}],\"usageMetadata\":{\"promptTokenCount\":1,\"candidatesTokenCount\":2,\"totalTokenCount\":3}}\n\n"
|
|
||||||
splitIdx := strings.Index(fullEvent, "chunk")
|
|
||||||
require.Greater(t, splitIdx, 0, "split marker should exist in test payload")
|
|
||||||
chunkPart1 := fullEvent[:splitIdx]
|
|
||||||
chunkPart2 := fullEvent[splitIdx:]
|
|
||||||
|
|
||||||
action1 := host.CallOnHttpStreamingResponseBody([]byte(chunkPart1), false)
|
|
||||||
require.Equal(t, types.ActionContinue, action1)
|
|
||||||
action2 := host.CallOnHttpStreamingResponseBody([]byte(chunkPart2), true)
|
|
||||||
require.Equal(t, types.ActionContinue, action2)
|
|
||||||
|
|
||||||
transformedResponseBody := host.GetResponseBody()
|
|
||||||
require.NotNil(t, transformedResponseBody)
|
|
||||||
responseStr := string(transformedResponseBody)
|
|
||||||
require.Contains(t, responseStr, "split chunk", "split SSE event should be reassembled and parsed")
|
|
||||||
require.Contains(t, responseStr, "data: [DONE]", "stream should end with [DONE]")
|
|
||||||
})
|
|
||||||
|
|
||||||
// 测试:thoughtSignature 很大时,单个 SSE 事件被拆成多段也能重组并成功解析
|
|
||||||
t.Run("vertex express mode streaming response body with huge thought signature split across chunks", 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/chat/completions"},
|
|
||||||
{":method", "POST"},
|
|
||||||
{"Content-Type", "application/json"},
|
|
||||||
})
|
|
||||||
|
|
||||||
requestBody := `{"model":"gemini-2.5-flash","messages":[{"role":"user","content":"test"}],"stream":true}`
|
|
||||||
host.CallOnHttpRequestBody([]byte(requestBody))
|
|
||||||
host.SetProperty([]string{"response", "code_details"}, []byte("via_upstream"))
|
|
||||||
host.CallOnHttpResponseHeaders([][2]string{
|
|
||||||
{":status", "200"},
|
|
||||||
{"Content-Type", "text/event-stream"},
|
|
||||||
})
|
|
||||||
|
|
||||||
hugeThoughtSignature := strings.Repeat("CmMBjz1rX4j+TQjtDy2rZxSdYOE1jUqDbRhWetraLlQNrkyaRNQZ/", 180)
|
|
||||||
fullEvent := "data: {\"candidates\":[{\"content\":{\"role\":\"model\",\"parts\":[{\"text\":\"thought-signature-merge-ok\",\"thoughtSignature\":\"" +
|
|
||||||
hugeThoughtSignature +
|
|
||||||
"\"}]},\"finishReason\":\"STOP\",\"index\":0}],\"usageMetadata\":{\"promptTokenCount\":28,\"candidatesTokenCount\":3589,\"totalTokenCount\":5240,\"thoughtsTokenCount\":1623}}\n\n"
|
|
||||||
|
|
||||||
signatureStart := strings.Index(fullEvent, "\"thoughtSignature\":\"")
|
|
||||||
require.Greater(t, signatureStart, 0, "thoughtSignature field should exist in test payload")
|
|
||||||
splitAt1 := signatureStart + len("\"thoughtSignature\":\"") + 700
|
|
||||||
splitAt2 := splitAt1 + 1600
|
|
||||||
require.Less(t, splitAt2, len(fullEvent)-1, "split indexes should keep payload in three chunks")
|
|
||||||
|
|
||||||
chunkPart1 := fullEvent[:splitAt1]
|
|
||||||
chunkPart2 := fullEvent[splitAt1:splitAt2]
|
|
||||||
chunkPart3 := fullEvent[splitAt2:]
|
|
||||||
|
|
||||||
action1 := host.CallOnHttpStreamingResponseBody([]byte(chunkPart1), false)
|
|
||||||
require.Equal(t, types.ActionContinue, action1)
|
|
||||||
firstBody := host.GetResponseBody()
|
|
||||||
require.Equal(t, 0, len(firstBody), "partial chunk should not be forwarded to client")
|
|
||||||
|
|
||||||
action2 := host.CallOnHttpStreamingResponseBody([]byte(chunkPart2), false)
|
|
||||||
require.Equal(t, types.ActionContinue, action2)
|
|
||||||
secondBody := host.GetResponseBody()
|
|
||||||
require.Equal(t, 0, len(secondBody), "partial chunk should not be forwarded to client")
|
|
||||||
|
|
||||||
action3 := host.CallOnHttpStreamingResponseBody([]byte(chunkPart3), true)
|
|
||||||
require.Equal(t, types.ActionContinue, action3)
|
|
||||||
|
|
||||||
transformedResponseBody := host.GetResponseBody()
|
|
||||||
require.NotNil(t, transformedResponseBody)
|
|
||||||
responseStr := string(transformedResponseBody)
|
|
||||||
require.Contains(t, responseStr, "thought-signature-merge-ok", "split huge thoughtSignature event should be reassembled and parsed")
|
|
||||||
require.Contains(t, responseStr, "data: [DONE]", "stream should end with [DONE]")
|
|
||||||
|
|
||||||
errorLogs := host.GetErrorLogs()
|
|
||||||
hasUnmarshalError := false
|
|
||||||
for _, log := range errorLogs {
|
|
||||||
if strings.Contains(log, "unable to unmarshal vertex response") {
|
|
||||||
hasUnmarshalError = true
|
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
require.False(t, hasUnmarshalError, "should not have vertex unmarshal errors for split huge thoughtSignature event")
|
require.True(t, hasStreamingLogs, "Should have streaming response processing logs")
|
||||||
})
|
|
||||||
|
|
||||||
// 测试:上游已发送 [DONE],框架再触发空的最后回调时不应重复输出 [DONE]
|
|
||||||
t.Run("vertex express mode streaming response body with upstream done and empty final callback", 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/chat/completions"},
|
|
||||||
{":method", "POST"},
|
|
||||||
{"Content-Type", "application/json"},
|
|
||||||
})
|
|
||||||
|
|
||||||
requestBody := `{"model":"gemini-2.5-flash","messages":[{"role":"user","content":"test"}],"stream":true}`
|
|
||||||
host.CallOnHttpRequestBody([]byte(requestBody))
|
|
||||||
host.SetProperty([]string{"response", "code_details"}, []byte("via_upstream"))
|
|
||||||
host.CallOnHttpResponseHeaders([][2]string{
|
|
||||||
{":status", "200"},
|
|
||||||
{"Content-Type", "text/event-stream"},
|
|
||||||
})
|
|
||||||
|
|
||||||
doneChunk := "data: [DONE]\n\n"
|
|
||||||
action1 := host.CallOnHttpStreamingResponseBody([]byte(doneChunk), false)
|
|
||||||
require.Equal(t, types.ActionContinue, action1)
|
|
||||||
firstBody := host.GetResponseBody()
|
|
||||||
require.NotNil(t, firstBody)
|
|
||||||
require.Contains(t, string(firstBody), "data: [DONE]", "first callback should output [DONE]")
|
|
||||||
|
|
||||||
action2 := host.CallOnHttpStreamingResponseBody([]byte{}, true)
|
|
||||||
require.Equal(t, types.ActionContinue, action2)
|
|
||||||
|
|
||||||
debugLogs := host.GetDebugLogs()
|
|
||||||
doneChunkLogCount := 0
|
|
||||||
for _, log := range debugLogs {
|
|
||||||
if strings.Contains(log, "=== modified response chunk: data: [DONE]") {
|
|
||||||
doneChunkLogCount++
|
|
||||||
}
|
|
||||||
}
|
|
||||||
require.Equal(t, 1, doneChunkLogCount, "[DONE] should only be emitted once when upstream already sent it")
|
|
||||||
})
|
|
||||||
|
|
||||||
// 测试:最后一个 chunk 缺少 SSE 结束空行时,isLastChunk=true 也应正确解析并输出
|
|
||||||
t.Run("vertex express mode streaming response body last chunk without terminator", 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/chat/completions"},
|
|
||||||
{":method", "POST"},
|
|
||||||
{"Content-Type", "application/json"},
|
|
||||||
})
|
|
||||||
|
|
||||||
requestBody := `{"model":"gemini-2.5-flash","messages":[{"role":"user","content":"test"}],"stream":true}`
|
|
||||||
host.CallOnHttpRequestBody([]byte(requestBody))
|
|
||||||
host.SetProperty([]string{"response", "code_details"}, []byte("via_upstream"))
|
|
||||||
host.CallOnHttpResponseHeaders([][2]string{
|
|
||||||
{":status", "200"},
|
|
||||||
{"Content-Type", "text/event-stream"},
|
|
||||||
})
|
|
||||||
|
|
||||||
lastChunkWithoutTerminator := "data: {\"candidates\":[{\"content\":{\"parts\":[{\"text\":\"no terminator\"}],\"role\":\"model\"},\"finishReason\":\"STOP\",\"index\":0}],\"usageMetadata\":{\"promptTokenCount\":2,\"candidatesTokenCount\":3,\"totalTokenCount\":5}}"
|
|
||||||
action := host.CallOnHttpStreamingResponseBody([]byte(lastChunkWithoutTerminator), true)
|
|
||||||
require.Equal(t, types.ActionContinue, action)
|
|
||||||
|
|
||||||
transformedResponseBody := host.GetResponseBody()
|
|
||||||
require.NotNil(t, transformedResponseBody)
|
|
||||||
responseStr := string(transformedResponseBody)
|
|
||||||
require.Contains(t, responseStr, "no terminator", "last chunk without terminator should still be parsed")
|
|
||||||
require.Contains(t, responseStr, "data: [DONE]", "stream should end with [DONE]")
|
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -1456,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 模式测试 ====================
|
// ==================== Vertex Raw 模式测试 ====================
|
||||||
|
|
||||||
func RunVertexRawModeOnHttpRequestHeadersTests(t *testing.T) {
|
func RunVertexRawModeOnHttpRequestHeadersTests(t *testing.T) {
|
||||||
|
|||||||
@@ -12,41 +12,41 @@ This release includes **6** updates, covering feature enhancements, bug fixes, a
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 📝 Full Changelog
|
## 📝 Full Change Log
|
||||||
|
|
||||||
### 🚀 New Features (Features)
|
### 🚀 New 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**: 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. \
|
**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**: 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.
|
**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) \
|
- **Related PR**: [#665](https://github.com/higress-group/higress-console/pull/665) \
|
||||||
**Contributor**: @johnlanni \
|
**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. \
|
**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. \
|
||||||
**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.
|
**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) \
|
- **Related PR**: [#661](https://github.com/higress-group/higress-console/pull/661) \
|
||||||
**Contributor**: @johnlanni \
|
**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. \
|
**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**: By enabling the lightweight mode, this feature optimizes AI routing performance, especially suitable for production environments, reducing response attribute buffering and improving system efficiency.
|
**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) \
|
- **Related PR**: [#657](https://github.com/higress-group/higress-console/pull/657) \
|
||||||
**Contributor**: @liangziccc \
|
**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. \
|
**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. \
|
||||||
**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.
|
**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) \
|
- **Related PR**: [#662](https://github.com/higress-group/higress-console/pull/662) \
|
||||||
**Contributor**: @johnlanni \
|
**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. \
|
**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**: 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.
|
**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) \
|
- **Related PR**: [#654](https://github.com/higress-group/higress-console/pull/654) \
|
||||||
**Contributor**: @fgksking \
|
**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. \
|
**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**: 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.
|
**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.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -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**: 此PR向内置插件添加了`pluginImageRegistry`和`pluginImageNamespace`配置支持,并通过环境变量来指定这些配置,使用户可以在不修改`plugins.properties`文件的情况下自定义插件镜像的位置。 \
|
**Change Log**: 新增插件镜像仓库和命名空间配置项,支持通过环境变量HIGRESS_ADMIN_WASM_PLUGIN_IMAGE_REGISTRY/NAMESPACE动态指定内置插件镜像地址,无需修改plugins.properties;同时在Helm Chart中集成对应values参数与部署模板渲染逻辑。 \
|
||||||
**Feature Value**: 新增的功能允许用户更灵活地管理其应用的插件镜像源,提高了系统的可配置性和便利性,特别对于需要使用特定镜像仓库或命名空间的用户来说十分有用。
|
**Feature Value**: 用户可在不同网络环境(如私有云、离线环境)灵活配置WASM插件镜像源,提升部署灵活性与安全性;降低运维成本,避免硬编码配置带来的维护困难和升级风险。
|
||||||
|
|
||||||
- **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**: 本PR为Zhipu AI及Claude引入了高级配置选项支持,包括自定义域、代码计划模式切换以及API版本设置。 \
|
**Change Log**: 新增Zhipu AI的Code Plan模式支持和Claude的API版本配置能力,通过扩展ZhipuAILlmProviderHandler和ClaudeLlmProviderHandler实现自定义域名、代码生成优化开关及API版本参数,提升大模型调用灵活性与场景适配性。 \
|
||||||
**Feature Value**: 增强了AI服务的灵活性和功能性,用户现在可以更精细地控制AI服务的行为,特别是对于需要优化代码生成能力的场景特别有用。
|
**Feature Value**: 用户可基于不同AI厂商特性启用代码专项生成模式(如Zhipu Code Plan)并精确控制Claude API版本,显著提升代码生成质量与兼容性,降低集成门槛,增强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**: 此PR为ai-statistics插件配置启用了轻量模式,添加了USE_DEFAULT_RESPONSE_ATTRIBUTES常量,并在AiRouteServiceImpl中应用了这一设置。 \
|
**Change Log**: 为AI统计插件引入轻量模式配置,新增USE_DEFAULT_ATTRIBUTES常量,并在AiRouteServiceImpl中启用use_default_response_attributes: true,减少响应属性采集开销,避免内存缓冲问题。 \
|
||||||
**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移除了原有的输入框搜索功能,新增了针对路由名称、域名等多个属性的多选下拉筛选框,并实现了中英文语言适配。 \
|
**Change Log**: PR在路由管理页移除了原有输入框搜索,新增路由名称、域名、路由条件、目标服务、请求授权五个字段的多选下拉筛选,并完成中英文国际化适配,支持多维度组合筛选(各字段内OR、字段间AND),提升数据过滤精准度。 \
|
||||||
**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**: 该PR修正了mcp-server OCI镜像路径从`mcp-server/all-in-one`到`plugins/mcp-server`的变更,以匹配新的插件结构。 \
|
**Change Log**: 修复了mcp-server插件OCI镜像路径未同步迁移的问题,将原路径mcp-server/all-in-one更新为plugins/mcp-server,适配新插件目录结构,确保插件加载和部署正常。 \
|
||||||
**Feature Value**: 通过更新镜像路径确保与新插件目录一致,从而保证服务正常运行,避免因路径错误导致的部署或运行问题。
|
**Feature Value**: 避免因镜像路径错误导致插件无法拉取或启动失败,保障Higress网关中mcp-server插件的稳定运行与无缝升级,提升用户在插件化场景下的部署可靠性。
|
||||||
|
|
||||||
- **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**: 此PR通过升级springdoc内置的swagger-ui依赖解决了请求体显示为空的问题,保证了API文档的准确性。 \
|
**Change Log**: 升级了springdoc依赖的swagger-ui版本,通过在pom.xml中引入更高版本的webjars-lo依赖和更新相关版本属性,修复了Swagger UI中请求体schema显示为空的问题。 \
|
||||||
**Feature Value**: 修复了Swagger UI中空请求体值的问题,提升了用户体验和开发者对API文档的信任度,确保接口测试与实际使用的一致性。
|
**Feature Value**: 用户在使用Higress控制台的API文档功能时,能正确查看和交互请求体结构,提升API调试体验与开发效率,避免因文档展示异常导致的接口调用误解。
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user