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
+37 -2
View File
@@ -6,6 +6,7 @@ import (
"errors"
"fmt"
"log/slog"
"net/http"
"os"
"regexp"
"strconv"
@@ -243,6 +244,13 @@ func (s *Service) observeObjectStorage(event string, provider string, bytes int,
}
}
func (s *Service) observeResultStorage(source string) {
observer, ok := s.billingMetrics.(interface{ ObserveResultStorage(string) })
if ok {
observer.ObserveResultStorage(source)
}
}
func (s *Service) Execute(ctx context.Context, task store.GatewayTask, user *auth.User) (Result, error) {
return s.execute(ctx, task, user, nil)
}
@@ -982,7 +990,7 @@ candidatesLoop:
}
walletReservationFinalized = true
s.logger.Warn("task succeeded but billing requires manual review", "taskID", task.ID, "error_category", "billing_calculation_failed")
output, hydrateErr := s.HydrateTaskResult(ctx, task.ID, response.Result)
output, hydrateErr := s.taskResultForSynchronousResponse(ctx, task, response.Result)
if hydrateErr != nil {
return Result{Task: review}, hydrateErr
}
@@ -1075,7 +1083,7 @@ candidatesLoop:
// time after the task is already durably complete.
return Result{Task: finished, Output: response.Result}, nil
}
output, hydrateErr := s.HydrateTaskResult(ctx, task.ID, response.Result)
output, hydrateErr := s.taskResultForSynchronousResponse(ctx, task, response.Result)
if hydrateErr != nil {
return Result{Task: finished}, hydrateErr
}
@@ -1272,6 +1280,33 @@ candidatesLoop:
return Result{Task: failed, Output: failed.Result}, lastErr
}
func (s *Service) taskResultForSynchronousResponse(ctx context.Context, task store.GatewayTask, result map[string]any) (map[string]any, error) {
if taskRequestsInlineMediaResult(task.Request) {
if rawBytes := SynchronousInlineResultBytes(result); rawBytes > MaxSynchronousInlineResponseBytes {
return nil, &clients.ClientError{
Code: "response_format_too_large",
Message: "synchronous Base64 response exceeds the 20 MiB limit",
StatusCode: http.StatusRequestEntityTooLarge,
Retryable: false,
Details: map[string]any{
"task_id": task.ID,
"query_url": "/api/v1/ai/result/" + task.ID,
"limit_bytes": MaxSynchronousInlineResponseBytes,
"actual_bytes": rawBytes,
"response_format": "url",
},
}
}
return s.HydrateTaskResult(ctx, task.ID, result)
}
return s.ProjectTaskResultURLs(ctx, task.ID, result)
}
func taskRequestsInlineMediaResult(request map[string]any) bool {
value, _ := request["response_format"].(string)
return strings.EqualFold(strings.TrimSpace(value), "b64_json")
}
func pricingCandidateKey(candidate store.RuntimeModelCandidate) string {
return firstNonEmptyString(candidate.PlatformModelID, candidate.PlatformID+":"+candidate.ModelName)
}