fix(media): 统一图片结果 URL 化并限制同步 Base64
将上游 URL 直接持久化,内联媒体经对象存储后仅保留 URL 与内部定位元数据;异步轮询、任务详情和幂等重放统一使用零对象读取的 URL 投影,并增加 64KiB 响应门禁。 OpenAI 图片接口接受 url 与 b64_json,同步 Base64 限制为 20MiB 和每 Pod 2 并发;新增历史结果迁移清零门禁、结果指标和 API GOMEMLIMIT。 验证:API go test ./...、go vet、聚焦 race、pnpm openapi、pnpm lint/test/build、迁移安全检查与 docker compose config 均通过。
This commit is contained in:
@@ -2,6 +2,7 @@ package httpapi
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
@@ -1166,6 +1167,10 @@ func (s *Server) createTask(kind string, compatible bool) http.Handler {
|
||||
return
|
||||
}
|
||||
}
|
||||
if err := validateMediaResponseFormat(kind, body); err != nil {
|
||||
writeTaskError(http.StatusBadRequest, err.Error(), map[string]any{"param": "response_format"}, "invalid_parameter")
|
||||
return
|
||||
}
|
||||
requestedModel := requestModelName(body)
|
||||
model := canonicalTaskModelName(kind, requestedModel)
|
||||
if model == "" {
|
||||
@@ -1550,6 +1555,12 @@ func writeProtocolCompatibleTaskResponse(runCtx context.Context, w http.Response
|
||||
return
|
||||
}
|
||||
|
||||
releaseInlineResponse, limitErr := acquireSynchronousInlineResponseSlot(runCtx, kind, task.Request)
|
||||
if limitErr != nil {
|
||||
writeProtocolError(w, targetProtocol, statusFromRunError(limitErr), runErrorMessage(limitErr), runErrorDetails(limitErr), runErrorCode(limitErr))
|
||||
return
|
||||
}
|
||||
defer releaseInlineResponse()
|
||||
result, runErr := executor.Execute(runCtx, task, user)
|
||||
if runErr != nil {
|
||||
if !requestStillConnected(r) {
|
||||
@@ -1566,13 +1577,107 @@ func writeProtocolCompatibleTaskResponse(runCtx context.Context, w http.Response
|
||||
if !requestStillConnected(r) {
|
||||
return
|
||||
}
|
||||
if wireResponseMatches(result.Wire, targetProtocol) {
|
||||
if synchronousInlineResponseRequested(kind, task.Request) {
|
||||
rawBytes := inlineMediaDecodedSize(result.Output)
|
||||
if rawBytes > maxSynchronousInlineResponseBytes {
|
||||
writeProtocolError(w, targetProtocol, http.StatusRequestEntityTooLarge, "synchronous Base64 response exceeds the 20 MiB limit", map[string]any{
|
||||
"task_id": result.Task.ID,
|
||||
"query_url": "/api/v1/ai/result/" + result.Task.ID,
|
||||
"limit_bytes": maxSynchronousInlineResponseBytes,
|
||||
"actual_bytes": rawBytes,
|
||||
"response_format": "url",
|
||||
}, "response_format_too_large")
|
||||
return
|
||||
}
|
||||
if rawBytes > 0 {
|
||||
w.Header().Set("X-Gateway-Response-Format", "b64_json")
|
||||
} else {
|
||||
w.Header().Set("X-Gateway-Response-Format", "url")
|
||||
}
|
||||
} else if mediaResultKind(kind) {
|
||||
w.Header().Set("X-Gateway-Response-Format", "url")
|
||||
}
|
||||
if !mediaResultKind(kind) && wireResponseMatches(result.Wire, targetProtocol) {
|
||||
writeWireResponse(w, result.Wire)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, easyAISynchronousTaskResponse(result.Task, result.Output))
|
||||
}
|
||||
|
||||
const maxSynchronousInlineResponseBytes = runner.MaxSynchronousInlineResponseBytes
|
||||
|
||||
var synchronousInlineResponseSlots = make(chan struct{}, 2)
|
||||
|
||||
func acquireSynchronousInlineResponseSlot(ctx context.Context, kind string, request map[string]any) (func(), error) {
|
||||
if !synchronousInlineResponseRequested(kind, request) {
|
||||
return func() {}, nil
|
||||
}
|
||||
select {
|
||||
case synchronousInlineResponseSlots <- struct{}{}:
|
||||
return func() { <-synchronousInlineResponseSlots }, nil
|
||||
case <-ctx.Done():
|
||||
return func() {}, &clients.ClientError{Code: "response_format_capacity_timeout", Message: "synchronous Base64 response capacity wait timed out", StatusCode: http.StatusServiceUnavailable, Retryable: true}
|
||||
}
|
||||
}
|
||||
|
||||
func synchronousInlineResponseRequested(kind string, request map[string]any) bool {
|
||||
if !mediaResultKind(kind) {
|
||||
return false
|
||||
}
|
||||
value, _ := request["response_format"].(string)
|
||||
return strings.EqualFold(strings.TrimSpace(value), "b64_json")
|
||||
}
|
||||
|
||||
func mediaResultKind(kind string) bool {
|
||||
return strings.HasPrefix(kind, "images.") || strings.HasPrefix(kind, "videos.") ||
|
||||
kind == "song.generations" || kind == "music.generations" || kind == "speech.generations"
|
||||
}
|
||||
|
||||
func inlineMediaDecodedSize(value any) int64 {
|
||||
switch typed := value.(type) {
|
||||
case map[string]any:
|
||||
var total int64
|
||||
for key, item := range typed {
|
||||
normalized := strings.ToLower(strings.ReplaceAll(strings.TrimSpace(key), "-", "_"))
|
||||
if raw, ok := item.(string); ok && (normalized == "b64_json" || strings.Contains(normalized, "base64")) {
|
||||
total += base64DecodedSize(raw)
|
||||
continue
|
||||
}
|
||||
total += inlineMediaDecodedSize(item)
|
||||
}
|
||||
return total
|
||||
case []any:
|
||||
var total int64
|
||||
for _, item := range typed {
|
||||
total += inlineMediaDecodedSize(item)
|
||||
}
|
||||
return total
|
||||
case string:
|
||||
raw := strings.TrimSpace(typed)
|
||||
if strings.HasPrefix(strings.ToLower(raw), "data:") {
|
||||
if comma := strings.IndexByte(raw, ','); comma >= 0 {
|
||||
return base64DecodedSize(raw[comma+1:])
|
||||
}
|
||||
}
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
func base64DecodedSize(value string) int64 {
|
||||
value = strings.TrimSpace(value)
|
||||
if value == "" {
|
||||
return 0
|
||||
}
|
||||
padding := 0
|
||||
if strings.HasSuffix(value, "=") {
|
||||
padding++
|
||||
}
|
||||
if strings.HasSuffix(value, "==") {
|
||||
padding++
|
||||
}
|
||||
return int64(base64.StdEncoding.DecodedLen(len(value)) - padding)
|
||||
}
|
||||
|
||||
func gatewayAPIV1Request(r *http.Request) bool {
|
||||
return r != nil && strings.HasPrefix(r.URL.Path, "/api/v1/")
|
||||
}
|
||||
@@ -1583,6 +1688,27 @@ func streamIncludeUsage(body map[string]any) bool {
|
||||
return includeUsage
|
||||
}
|
||||
|
||||
func validateMediaResponseFormat(kind string, body map[string]any) error {
|
||||
if !strings.HasPrefix(kind, "images.") {
|
||||
return nil
|
||||
}
|
||||
value, exists := body["response_format"]
|
||||
if !exists || value == nil {
|
||||
return nil
|
||||
}
|
||||
format, ok := value.(string)
|
||||
if !ok {
|
||||
return errors.New("response_format must be url or b64_json")
|
||||
}
|
||||
switch strings.ToLower(strings.TrimSpace(format)) {
|
||||
case "url", "b64_json":
|
||||
body["response_format"] = strings.ToLower(strings.TrimSpace(format))
|
||||
return nil
|
||||
default:
|
||||
return errors.New("response_format must be url or b64_json")
|
||||
}
|
||||
}
|
||||
|
||||
func asyncRequest(r *http.Request) bool {
|
||||
value := strings.TrimSpace(strings.ToLower(r.Header.Get("x-async")))
|
||||
return value == "1" || value == "true" || value == "yes" || value == "on"
|
||||
|
||||
Reference in New Issue
Block a user