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:
2026-08-05 18:15:06 +08:00
parent f9b945e4aa
commit b13392ef50
22 changed files with 1367 additions and 176 deletions
+127 -1
View File
@@ -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"