fix(openai): sanitize empty base64 input images
This commit is contained in:
@@ -1931,6 +1931,11 @@ func (s *OpenAIGatewayService) Forward(ctx context.Context, c *gin.Context, acco
|
||||
}
|
||||
}
|
||||
|
||||
if sanitizeEmptyBase64InputImagesInOpenAIRequestBodyMap(reqBody) {
|
||||
bodyModified = true
|
||||
disablePatch()
|
||||
}
|
||||
|
||||
// Re-serialize body only if modified
|
||||
if bodyModified {
|
||||
serializedByPatch := false
|
||||
@@ -2358,6 +2363,14 @@ func (s *OpenAIGatewayService) forwardOpenAIPassthrough(
|
||||
reqStream = gjson.GetBytes(body, "stream").Bool()
|
||||
}
|
||||
|
||||
sanitizedBody, sanitized, err := sanitizeEmptyBase64InputImagesInOpenAIBody(body)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if sanitized {
|
||||
body = sanitizedBody
|
||||
}
|
||||
|
||||
logger.LegacyPrintf("service.openai_gateway",
|
||||
"[OpenAI 自动透传] 命中自动透传分支: account=%d name=%s type=%s model=%s stream=%v",
|
||||
account.ID,
|
||||
@@ -4691,6 +4704,123 @@ func normalizeOpenAIServiceTier(raw string) *string {
|
||||
}
|
||||
}
|
||||
|
||||
func sanitizeEmptyBase64InputImagesInOpenAIBody(body []byte) ([]byte, bool, error) {
|
||||
if len(body) == 0 || !bytes.Contains(body, []byte(`"image_url"`)) || !bytes.Contains(body, []byte(`base64,`)) {
|
||||
return body, false, nil
|
||||
}
|
||||
|
||||
var reqBody map[string]any
|
||||
if err := json.Unmarshal(body, &reqBody); err != nil {
|
||||
return body, false, fmt.Errorf("sanitize request body: %w", err)
|
||||
}
|
||||
if !sanitizeEmptyBase64InputImagesInOpenAIRequestBodyMap(reqBody) {
|
||||
return body, false, nil
|
||||
}
|
||||
normalized, err := json.Marshal(reqBody)
|
||||
if err != nil {
|
||||
return body, false, fmt.Errorf("serialize sanitized request body: %w", err)
|
||||
}
|
||||
return normalized, true, nil
|
||||
}
|
||||
|
||||
func sanitizeEmptyBase64InputImagesInOpenAIRequestBodyMap(reqBody map[string]any) bool {
|
||||
if reqBody == nil {
|
||||
return false
|
||||
}
|
||||
input, ok := reqBody["input"]
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
normalizedInput, changed := sanitizeEmptyBase64InputImagesInOpenAIInput(input)
|
||||
if !changed {
|
||||
return false
|
||||
}
|
||||
reqBody["input"] = normalizedInput
|
||||
return true
|
||||
}
|
||||
|
||||
func sanitizeEmptyBase64InputImagesInOpenAIInput(input any) (any, bool) {
|
||||
items, ok := input.([]any)
|
||||
if !ok {
|
||||
return input, false
|
||||
}
|
||||
|
||||
normalizedItems := make([]any, 0, len(items))
|
||||
changed := false
|
||||
for _, item := range items {
|
||||
itemMap, ok := item.(map[string]any)
|
||||
if !ok {
|
||||
normalizedItems = append(normalizedItems, item)
|
||||
continue
|
||||
}
|
||||
if shouldDropEmptyBase64InputImagePart(itemMap) {
|
||||
changed = true
|
||||
continue
|
||||
}
|
||||
content, ok := itemMap["content"]
|
||||
if !ok {
|
||||
normalizedItems = append(normalizedItems, itemMap)
|
||||
continue
|
||||
}
|
||||
parts, ok := content.([]any)
|
||||
if !ok {
|
||||
normalizedItems = append(normalizedItems, itemMap)
|
||||
continue
|
||||
}
|
||||
|
||||
normalizedParts := make([]any, 0, len(parts))
|
||||
itemChanged := false
|
||||
for _, part := range parts {
|
||||
if shouldDropEmptyBase64InputImagePart(part) {
|
||||
changed = true
|
||||
itemChanged = true
|
||||
continue
|
||||
}
|
||||
normalizedParts = append(normalizedParts, part)
|
||||
}
|
||||
if itemChanged {
|
||||
if len(normalizedParts) == 0 {
|
||||
continue
|
||||
}
|
||||
itemMap["content"] = normalizedParts
|
||||
}
|
||||
normalizedItems = append(normalizedItems, itemMap)
|
||||
}
|
||||
if !changed {
|
||||
return input, false
|
||||
}
|
||||
return normalizedItems, true
|
||||
}
|
||||
|
||||
func shouldDropEmptyBase64InputImagePart(part any) bool {
|
||||
partMap, ok := part.(map[string]any)
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
typeValue, _ := partMap["type"].(string)
|
||||
if strings.TrimSpace(typeValue) != "input_image" {
|
||||
return false
|
||||
}
|
||||
imageURL, _ := partMap["image_url"].(string)
|
||||
return isEmptyBase64DataURI(imageURL)
|
||||
}
|
||||
|
||||
func isEmptyBase64DataURI(raw string) bool {
|
||||
if !strings.HasPrefix(raw, "data:") {
|
||||
return false
|
||||
}
|
||||
rest := strings.TrimPrefix(raw, "data:")
|
||||
semicolonIdx := strings.Index(rest, ";")
|
||||
if semicolonIdx < 0 {
|
||||
return false
|
||||
}
|
||||
rest = rest[semicolonIdx+1:]
|
||||
if !strings.HasPrefix(rest, "base64,") {
|
||||
return false
|
||||
}
|
||||
return strings.TrimSpace(strings.TrimPrefix(rest, "base64,")) == ""
|
||||
}
|
||||
|
||||
func getOpenAIRequestBodyMap(c *gin.Context, body []byte) (map[string]any, error) {
|
||||
if c != nil {
|
||||
if cached, ok := c.Get(OpenAIParsedRequestBodyKey); ok {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
@@ -139,3 +140,61 @@ func TestGetOpenAIRequestBodyMap_WriteBackContextCache(t *testing.T) {
|
||||
require.True(t, ok)
|
||||
require.Equal(t, got, cachedMap)
|
||||
}
|
||||
|
||||
func TestSanitizeEmptyBase64InputImagesInOpenAIRequestBodyMap(t *testing.T) {
|
||||
var reqBody map[string]any
|
||||
require.NoError(t, json.Unmarshal([]byte(`{
|
||||
"model":"gpt-5.4",
|
||||
"input":[
|
||||
{"role":"user","content":[
|
||||
{"type":"input_text","text":"Describe this"},
|
||||
{"type":"input_image","image_url":"data:image/png;base64, "},
|
||||
{"type":"input_image","image_url":"data:image/png;base64,abc123"}
|
||||
]},
|
||||
{"role":"user","content":[
|
||||
{"type":"input_image","image_url":"data:image/png;base64,"}
|
||||
]},
|
||||
{"type":"input_image","image_url":"data:image/png;base64,"},
|
||||
{"type":"input_image","image_url":"data:image/png;base64,top-level-valid"}
|
||||
]
|
||||
}`), &reqBody))
|
||||
|
||||
require.True(t, sanitizeEmptyBase64InputImagesInOpenAIRequestBodyMap(reqBody))
|
||||
|
||||
normalized, err := json.Marshal(reqBody)
|
||||
require.NoError(t, err)
|
||||
require.JSONEq(t, `{
|
||||
"model":"gpt-5.4",
|
||||
"input":[
|
||||
{"role":"user","content":[
|
||||
{"type":"input_text","text":"Describe this"},
|
||||
{"type":"input_image","image_url":"data:image/png;base64,abc123"}
|
||||
]},
|
||||
{"type":"input_image","image_url":"data:image/png;base64,top-level-valid"}
|
||||
]
|
||||
}`, string(normalized))
|
||||
}
|
||||
|
||||
func TestSanitizeEmptyBase64InputImagesInOpenAIBody(t *testing.T) {
|
||||
body, changed, err := sanitizeEmptyBase64InputImagesInOpenAIBody([]byte(`{
|
||||
"model":"gpt-5.4",
|
||||
"stream":true,
|
||||
"input":[
|
||||
{"role":"user","content":[
|
||||
{"type":"input_text","text":"Describe this"},
|
||||
{"type":"input_image","image_url":"data:image/png;base64,"}
|
||||
]}
|
||||
]
|
||||
}`))
|
||||
require.NoError(t, err)
|
||||
require.True(t, changed)
|
||||
require.JSONEq(t, `{
|
||||
"model":"gpt-5.4",
|
||||
"stream":true,
|
||||
"input":[
|
||||
{"role":"user","content":[
|
||||
{"type":"input_text","text":"Describe this"}
|
||||
]}
|
||||
]
|
||||
}`, string(body))
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user