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
+1 -1
View File
@@ -12,7 +12,7 @@ func (s *Server) hydrateTaskResult(ctx context.Context, task store.GatewayTask)
if task.Status != "succeeded" || len(task.Result) == 0 {
return task, nil
}
result, err := s.runner.HydrateTaskResult(ctx, task.ID, task.Result)
result, err := s.runner.ProjectTaskResultURLs(ctx, task.ID, task.Result)
if err != nil {
return store.GatewayTask{}, err
}
@@ -2,6 +2,7 @@ package httpapi
import (
"context"
"encoding/base64"
"encoding/json"
"errors"
"net/http"
@@ -14,6 +15,55 @@ import (
"github.com/easyai/easyai-ai-gateway/apps/api/internal/store"
)
func TestSynchronousInlineResponseLimitAndCapacity(t *testing.T) {
request := map[string]any{"response_format": "b64_json"}
if !synchronousInlineResponseRequested("images.generations", request) {
t.Fatal("explicit b64_json image response was not detected")
}
encoded := strings.Repeat("A", base64.StdEncoding.EncodedLen(int(maxSynchronousInlineResponseBytes+1)))
if got := inlineMediaDecodedSize(map[string]any{"data": []any{map[string]any{"b64_json": encoded}}}); got <= maxSynchronousInlineResponseBytes {
t.Fatalf("decoded size=%d", got)
}
if got := base64DecodedSize(base64.StdEncoding.EncodeToString([]byte{1})); got != 1 {
t.Fatalf("padded Base64 decoded size=%d", got)
}
releaseFirst, err := acquireSynchronousInlineResponseSlot(t.Context(), "images.generations", request)
if err != nil {
t.Fatal(err)
}
defer releaseFirst()
releaseSecond, err := acquireSynchronousInlineResponseSlot(t.Context(), "images.generations", request)
if err != nil {
t.Fatal(err)
}
defer releaseSecond()
cancelled, cancel := context.WithCancel(t.Context())
cancel()
_, err = acquireSynchronousInlineResponseSlot(cancelled, "images.generations", request)
if clients.ErrorCode(err) != "response_format_capacity_timeout" {
t.Fatalf("unexpected capacity error: %v", err)
}
}
func TestValidateMediaResponseFormat(t *testing.T) {
for _, value := range []string{"url", "b64_json", " B64_JSON "} {
body := map[string]any{"response_format": value}
if err := validateMediaResponseFormat("images.generations", body); err != nil {
t.Fatalf("validate %q: %v", value, err)
}
}
for _, value := range []any{"base64", 1, map[string]any{"type": "url"}} {
if err := validateMediaResponseFormat("images.generations", map[string]any{"response_format": value}); err == nil {
t.Fatalf("accepted invalid response_format: %#v", value)
}
}
if err := validateMediaResponseFormat("chat.completions", map[string]any{"response_format": map[string]any{"type": "json_object"}}); err != nil {
t.Fatalf("image validation affected chat response_format: %v", err)
}
}
func TestProtocolAPIKeyStoreFailureIs503InsteadOf401(t *testing.T) {
authenticator := auth.New("test-secret", "", "")
authenticator.LocalAPIKeyVerifier = func(context.Context, string) (*auth.User, error) {
+58 -40
View File
@@ -1,7 +1,6 @@
package httpapi
import (
"encoding/json"
"net/http"
"strings"
@@ -109,32 +108,31 @@ func easyAIFileUploadResponse(upload map[string]any) map[string]any {
func easyAITaskResultResponse(task store.GatewayTask) map[string]any {
sourceResult := cloneEasyAIMap(task.Result)
cleanResult := cloneEasyAIMap(sourceResult)
delete(cleanResult, "raw")
delete(cleanResult, "raw_data")
normalizeEasyAIInlineMediaFields(cleanResult)
data := easyAITaskResultData(task, sourceResult)
status := easyAITaskResultStatus(task.Status)
if status == "failed" {
data = []any{}
}
cleanResult["data"] = data
cleanResult["output_content"] = data
response := cloneEasyAIMap(cleanResult)
response["status"] = status
response["task_id"] = task.ID
response["taskId"] = task.ID
response["query_url"] = "/api/v1/ai/result/" + task.ID
response["created"] = task.CreatedAt.UnixMilli()
response["data"] = data
response["output_content"] = data
response["output"] = easyAIOutputURLs(data)
response["result"] = cleanResult
output := easyAIOutputURLs(data)
compatResult := map[string]any{
"data": data,
"output": output,
"output_content": data,
}
response := map[string]any{
"status": status,
"task_id": task.ID,
"taskId": task.ID,
"query_url": "/api/v1/ai/result/" + task.ID,
"created": task.CreatedAt.UnixMilli(),
"data": data,
"output": output,
"output_content": data,
"result": compatResult,
}
upstreamTaskID := firstNonEmpty(
easyAIString(cleanResult["upstream_task_id"]),
easyAIString(sourceResult["upstream_task_id"]),
task.RemoteTaskID,
)
if upstreamTaskID != "" {
@@ -144,12 +142,14 @@ func easyAITaskResultResponse(task store.GatewayTask) map[string]any {
response["usage"] = task.Usage
}
cancelState := runner.DescribeTaskCancellation(task)
response["cancellable"] = cancelState.Cancellable
response["submitted"] = cancelState.Submitted
if status != "success" {
cancelState := runner.DescribeTaskCancellation(task)
response["cancellable"] = cancelState.Cancellable
response["submitted"] = cancelState.Submitted
}
message := firstNonEmpty(
easyAIString(cleanResult["message"]),
easyAIString(sourceResult["message"]),
task.ErrorMessage,
task.Error,
task.Message,
@@ -164,10 +164,10 @@ func easyAITaskResultResponse(task store.GatewayTask) map[string]any {
message = "任务执行失败"
}
}
if message != "" {
if message != "" && status != "success" {
response["message"] = message
}
if code := firstNonEmpty(task.ErrorCode, easyAIString(cleanResult["code"])); code != "" || status == "failed" {
if code := firstNonEmpty(task.ErrorCode, easyAIString(sourceResult["code"])); code != "" || status == "failed" {
standard := publicTaskError(task)
if code != "" && task.ErrorCode == "" {
standard = publicerror.WithIDs(publicerror.FromFields(code, message, 0, false), task.RequestID, task.ID)
@@ -268,19 +268,24 @@ func normalizeEasyAIOutputItems(task store.GatewayTask, items []any) []any {
if len(output) == 0 {
continue
}
delete(output, "raw_data")
normalizeEasyAIInlineMediaFields(output)
mediaURL := easyAIOutputURL(output)
if mediaURL != "" {
output["url"] = mediaURL
if mediaURL == "" {
continue
}
if strings.TrimSpace(easyAIString(output["type"])) == "" {
if outputType := easyAIOutputType(task, output, mediaURL); outputType != "" {
output["type"] = outputType
lightweight := map[string]any{"url": mediaURL}
for _, key := range []string{"type", "mime_type", "width", "height", "duration", "format", "seed", "revised_prompt"} {
if value, exists := output[key]; exists && value != nil {
lightweight[key] = value
}
}
normalized = append(normalized, output)
if strings.TrimSpace(easyAIString(lightweight["type"])) == "" {
if outputType := easyAIOutputType(task, output, mediaURL); outputType != "" {
lightweight["type"] = outputType
}
}
normalized = append(normalized, lightweight)
}
return normalized
}
@@ -492,13 +497,26 @@ func cloneEasyAIMap(source map[string]any) map[string]any {
if len(source) == 0 {
return map[string]any{}
}
raw, err := json.Marshal(source)
if err != nil {
return map[string]any{}
}
var result map[string]any
if err := json.Unmarshal(raw, &result); err != nil {
return map[string]any{}
result := make(map[string]any, len(source))
for key, value := range source {
result[key] = cloneEasyAIValue(value)
}
return result
}
func cloneEasyAIValue(value any) any {
switch typed := value.(type) {
case map[string]any:
return cloneEasyAIMap(typed)
case []any:
next := make([]any, len(typed))
for index, item := range typed {
next[index] = cloneEasyAIValue(item)
}
return next
case []string:
return append([]string(nil), typed...)
default:
return value
}
}
+106 -33
View File
@@ -4,6 +4,9 @@ import (
"encoding/json"
"net/http"
"net/http/httptest"
"runtime"
"strings"
"sync"
"testing"
"time"
@@ -11,6 +14,98 @@ import (
"github.com/easyai/easyai-ai-gateway/apps/api/internal/store"
)
func TestEasyAITaskResultURLResponseIsCompact(t *testing.T) {
task := store.GatewayTask{
ID: "image-url-compact", Kind: "images.generations", Status: "succeeded",
Result: map[string]any{"data": []any{map[string]any{
"type": "image", "url": "https://cdn.example/result.png", "mime_type": "image/png",
"provider_payload": strings.Repeat("x", 4096), "upload": map[string]any{"objectKey": "secret"},
}}},
}
runtime.GC()
var before runtime.MemStats
runtime.ReadMemStats(&before)
response := easyAITaskResultResponse(task)
payload, err := json.Marshal(response)
if err != nil {
t.Fatal(err)
}
var after runtime.MemStats
runtime.ReadMemStats(&after)
if len(payload) >= maxAsyncMediaResultResponseBytes {
t.Fatalf("URL response bytes=%d", len(payload))
}
if allocated := after.TotalAlloc - before.TotalAlloc; allocated >= 1<<20 {
t.Fatalf("URL response allocated %d bytes", allocated)
}
if !json.Valid(payload) {
t.Fatal("URL response is not valid JSON")
}
if strings.Contains(string(payload), "provider_payload") || strings.Contains(string(payload), "objectKey") {
t.Fatalf("internal provider or object-storage fields leaked: %s", payload)
}
}
func TestWriteAsyncMediaResultJSONSetsURLAndDeprecationHeaders(t *testing.T) {
recorder := httptest.NewRecorder()
writeAsyncMediaResultJSON(recorder, map[string]any{
"status": "success", "data": []any{map[string]any{"url": "https://cdn.example/result.png"}},
}, nil)
if recorder.Code != http.StatusOK {
t.Fatalf("status=%d body=%s", recorder.Code, recorder.Body.String())
}
if recorder.Header().Get("X-Gateway-Response-Format") != "url" || recorder.Header().Get("Deprecation") != "true" {
t.Fatalf("unexpected headers: %#v", recorder.Header())
}
}
func TestWriteAsyncMediaResultJSONRejectsOversizedPayload(t *testing.T) {
recorder := httptest.NewRecorder()
writeAsyncMediaResultJSON(recorder, map[string]any{"data": strings.Repeat("x", maxAsyncMediaResultResponseBytes)}, nil)
if recorder.Code != http.StatusInternalServerError || !strings.Contains(recorder.Body.String(), "result_response_too_large") {
t.Fatalf("status=%d body=%s", recorder.Code, recorder.Body.String())
}
}
func TestConcurrentURLResultResponsesRemainBounded(t *testing.T) {
task := store.GatewayTask{
ID: "image-url-load", Kind: "images.generations", Status: "succeeded",
Result: map[string]any{"data": []any{map[string]any{
"type": "image", "url": "https://cdn.example/result.png", "mime_type": "image/png",
}}},
}
runtime.GC()
var before runtime.MemStats
runtime.ReadMemStats(&before)
var wait sync.WaitGroup
errors := make(chan string, 1000)
for index := 0; index < 1000; index++ {
wait.Add(1)
go func() {
defer wait.Done()
payload, err := json.Marshal(easyAITaskResultResponse(task))
if err != nil {
errors <- err.Error()
return
}
if len(payload) >= maxAsyncMediaResultResponseBytes {
errors <- "response exceeded URL-only size gate"
}
}()
}
wait.Wait()
close(errors)
for message := range errors {
t.Fatal(message)
}
var after runtime.MemStats
runtime.ReadMemStats(&after)
if allocated := after.TotalAlloc - before.TotalAlloc; allocated >= 256<<20 {
t.Fatalf("1000 concurrent URL responses allocated %d bytes", allocated)
}
}
func TestEasyAITaskAcceptedResponseKeepsGatewayFieldsAndAddsLegacyFields(t *testing.T) {
task := store.GatewayTask{
ID: "task-accepted-1",
@@ -119,8 +214,8 @@ func TestEasyAITaskResultResponseNormalizesMediaOutputs(t *testing.T) {
t.Fatalf("output_content is not synchronized: %+v", got)
}
if item.wantCount == 0 {
if got["voice_id"] != "voice-test-1" {
t.Fatalf("voice clone fields were lost: %+v", got)
if got["voice_id"] != nil || got["cloned_voice"] != nil {
t.Fatalf("provider-specific fields leaked: %+v", got)
}
return
}
@@ -139,7 +234,7 @@ func TestEasyAITaskResultResponseNormalizesMediaOutputs(t *testing.T) {
}
}
func TestEasyAITaskResultResponsePreservesBase64ImageOutput(t *testing.T) {
func TestEasyAITaskResultResponseDropsBase64ImageOutput(t *testing.T) {
base64Payload := "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAusB9Y9ZlB8AAAAASUVORK5CYII="
task := store.GatewayTask{
ID: "image-base64-1", Kind: "images.generations", Status: "succeeded",
@@ -151,20 +246,8 @@ func TestEasyAITaskResultResponsePreservesBase64ImageOutput(t *testing.T) {
got := easyAITaskResultResponse(task)
data, _ := got["data"].([]any)
if len(data) != 1 {
t.Fatalf("unexpected base64 output count: %+v", got)
}
output, _ := data[0].(map[string]any)
if output["type"] != "image" || output["b64_json"] != base64Payload || output["mime_type"] != "image/png" {
t.Fatalf("base64 image output was not preserved: %+v", output)
}
if _, exposedAsURL := output["url"]; exposedAsURL {
t.Fatalf("base64 image must not be exposed as a URL: %+v", output)
}
outputContent, _ := got["output_content"].([]any)
content, _ := outputContent[0].(map[string]any)
if content["b64_json"] != base64Payload {
t.Fatalf("output_content lost base64 image: %+v", outputContent)
if len(data) != 0 {
t.Fatalf("Base64 output leaked into URL-only response: %+v", got)
}
urls, _ := got["output"].([]string)
if len(urls) != 0 {
@@ -172,7 +255,7 @@ func TestEasyAITaskResultResponsePreservesBase64ImageOutput(t *testing.T) {
}
}
func TestEasyAITaskResultResponseMovesImageDataURLToB64JSON(t *testing.T) {
func TestEasyAITaskResultResponseDropsImageDataURL(t *testing.T) {
base64Payload := "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAusB9Y9ZlB8AAAAASUVORK5CYII="
task := store.GatewayTask{
ID: "image-data-url-1", Kind: "images.generations", Status: "succeeded",
@@ -183,12 +266,8 @@ func TestEasyAITaskResultResponseMovesImageDataURLToB64JSON(t *testing.T) {
got := easyAITaskResultResponse(task)
data, _ := got["data"].([]any)
output, _ := data[0].(map[string]any)
if output["b64_json"] != base64Payload || output["mime_type"] != "image/png" || output["type"] != "image" {
t.Fatalf("image data URL was not normalized: %+v", output)
}
if _, exists := output["url"]; exists {
t.Fatalf("image data URL should move out of the URL field: %+v", output)
if len(data) != 0 {
t.Fatalf("data URL leaked into URL-only response: %+v", got)
}
}
@@ -220,7 +299,7 @@ func TestEasyAITaskResultResponseForwardsSafeUpstreamParameterMessage(t *testing
}
}
func TestEasyAITaskResultResponsePreservesAudioDataURLAsContent(t *testing.T) {
func TestEasyAITaskResultResponseDropsAudioDataURL(t *testing.T) {
base64Payload := "SUQzBAAAAAAAI1RTU0UAAAAPAAADTGF2ZjYwLjMuMTAwAAAAAAAAAAAAAAD/"
task := store.GatewayTask{
ID: "audio-data-url-1", Kind: "speech.generations", Status: "succeeded",
@@ -231,14 +310,8 @@ func TestEasyAITaskResultResponsePreservesAudioDataURLAsContent(t *testing.T) {
got := easyAITaskResultResponse(task)
data, _ := got["data"].([]any)
output, _ := data[0].(map[string]any)
if output["content"] != "data:audio/mpeg;base64,"+base64Payload ||
output["mime_type"] != "audio/mpeg" ||
output["type"] != "audio" {
t.Fatalf("audio data URL was not normalized to inline content: %+v", output)
}
if _, exists := output["audio_url"]; exists {
t.Fatalf("audio data URL should move out of the URL field: %+v", output)
if len(data) != 0 {
t.Fatalf("audio data URL leaked into URL-only response: %+v", got)
}
urls, _ := got["output"].([]string)
if len(urls) != 0 {
+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"
+18 -16
View File
@@ -295,27 +295,29 @@ type TaskNextLinks struct {
}
type EasyAIGeneratedResponse struct {
Status string `json:"status" example:"success" enums:"submitted,process,success,failed"`
TaskID string `json:"task_id" example:"9f4d8f3d-5f5f-4bb7-a4be-344a9f930e25"`
CamelTaskID string `json:"taskId,omitempty" example:"9f4d8f3d-5f5f-4bb7-a4be-344a9f930e25"`
UpstreamTaskID string `json:"upstream_task_id,omitempty" example:"provider-task-123"`
QueryURL string `json:"query_url,omitempty" example:"/api/v1/ai/result/9f4d8f3d-5f5f-4bb7-a4be-344a9f930e25"`
Created int64 `json:"created" example:"1784772000000"`
Message string `json:"message,omitempty"`
Code string `json:"code,omitempty"`
Data []EasyAIMediaOutput `json:"data"`
Output []string `json:"output"`
OutputContent []EasyAIMediaOutput `json:"output_content"`
Cancellable bool `json:"cancellable"`
Submitted bool `json:"submitted"`
Result map[string]interface{} `json:"result,omitempty"`
Usage map[string]interface{} `json:"usage,omitempty"`
Status string `json:"status" example:"success" enums:"submitted,process,success,failed"`
TaskID string `json:"task_id" example:"9f4d8f3d-5f5f-4bb7-a4be-344a9f930e25"`
CamelTaskID string `json:"taskId,omitempty" example:"9f4d8f3d-5f5f-4bb7-a4be-344a9f930e25"`
UpstreamTaskID string `json:"upstream_task_id,omitempty" example:"provider-task-123"`
QueryURL string `json:"query_url,omitempty" example:"/api/v1/ai/result/9f4d8f3d-5f5f-4bb7-a4be-344a9f930e25"`
Created int64 `json:"created" example:"1784772000000"`
Message string `json:"message,omitempty"`
Code string `json:"code,omitempty"`
Data []EasyAIMediaOutput `json:"data"`
Output []string `json:"output"`
// OutputContent is a one-release URL-only compatibility alias for Data.
OutputContent []EasyAIMediaOutput `json:"output_content"`
Cancellable bool `json:"cancellable"`
Submitted bool `json:"submitted"`
// Result is a one-release URL-only compatibility alias and is deprecated.
Result map[string]interface{} `json:"result,omitempty"`
Usage map[string]interface{} `json:"usage,omitempty"`
}
type EasyAIMediaOutput struct {
Type string `json:"type,omitempty" example:"video" enums:"image,video,audio,file,text"`
URL string `json:"url,omitempty" example:"https://cdn.example.com/output.mp4"`
// B64JSON is retained when result media transfer is disabled.
// B64JSON is available only to bounded synchronous OpenAI image responses.
B64JSON string `json:"b64_json,omitempty"`
ImageURL string `json:"image_url,omitempty"`
VideoURL string `json:"video_url,omitempty"`
@@ -22,6 +22,11 @@ func publicGatewayTask(task store.GatewayTask) store.GatewayTask {
task.Attempts = append([]store.TaskAttempt(nil), task.Attempts...)
for index := range task.Attempts {
attempt := &task.Attempts[index]
// User-facing task details expose the canonical task result. Attempt
// snapshots can duplicate large input media or retain raw provider output,
// so keep them available only on dedicated administrative surfaces.
attempt.RequestSnapshot = nil
attempt.ResponseSnapshot = nil
if attempt.ErrorCode == "" && attempt.ErrorMessage == "" {
continue
}
@@ -66,7 +66,7 @@ func TestPublicHTTPErrorIncludesSafeUpstreamParameterMessage(t *testing.T) {
}
}
func TestPublicGatewayTaskSanitizesCopyAndKeepsRawAuditFields(t *testing.T) {
func TestPublicGatewayTaskSanitizesCopyAndKeepsRawAuditFieldsInternal(t *testing.T) {
rawMessage := `404 page not found: {"privateProject":"secret"}`
task := store.GatewayTask{
ID: "task-1",
@@ -74,11 +74,13 @@ func TestPublicGatewayTaskSanitizesCopyAndKeepsRawAuditFields(t *testing.T) {
ErrorCode: "http_404",
ErrorMessage: rawMessage,
Attempts: []store.TaskAttempt{{
AttemptNo: 1,
Status: "failed",
StatusCode: http.StatusNotFound,
ErrorCode: "http_404",
ErrorMessage: rawMessage,
AttemptNo: 1,
Status: "failed",
StatusCode: http.StatusNotFound,
ErrorCode: "http_404",
ErrorMessage: rawMessage,
RequestSnapshot: map[string]any{"image_base64": "private-input"},
ResponseSnapshot: map[string]any{"provider_payload": "private-output"},
}},
}
@@ -89,9 +91,15 @@ func TestPublicGatewayTaskSanitizesCopyAndKeepsRawAuditFields(t *testing.T) {
if strings.Contains(public.ErrorMessage, "secret") || public.Attempts[0].ErrorMessage == rawMessage {
t.Fatalf("public task leaked upstream details: %+v", public)
}
if public.Attempts[0].RequestSnapshot != nil || public.Attempts[0].ResponseSnapshot != nil {
t.Fatalf("public task leaked attempt snapshots: %+v", public.Attempts[0])
}
if task.ErrorCode != "http_404" || task.ErrorMessage != rawMessage || task.Attempts[0].ErrorCode != "http_404" || task.Attempts[0].ErrorMessage != rawMessage {
t.Fatalf("public conversion mutated raw audit fields: %+v", task)
}
if task.Attempts[0].RequestSnapshot == nil || task.Attempts[0].ResponseSnapshot == nil {
t.Fatalf("public conversion mutated internal snapshots: %+v", task.Attempts[0])
}
}
func TestPublicGatewayTaskForwardsSafeUpstreamParameterMessage(t *testing.T) {
+31
View File
@@ -9,12 +9,43 @@ import (
"github.com/easyai/easyai-ai-gateway/apps/api/internal/publicerror"
)
const maxAsyncMediaResultResponseBytes = 64 << 10
func writeJSON(w http.ResponseWriter, status int, value any) {
w.Header().Set("Content-Type", "application/json; charset=utf-8")
w.WriteHeader(status)
_ = json.NewEncoder(w).Encode(value)
}
func writeAsyncMediaResultJSON(w http.ResponseWriter, value any, observer interface {
ObserveResultDelivery(string, int64)
}) {
payload, err := json.Marshal(value)
if err != nil {
writeError(w, http.StatusInternalServerError, "encode task result failed", "internal_error")
return
}
if len(payload) > maxAsyncMediaResultResponseBytes {
if observer != nil {
observer.ObserveResultDelivery("poll_oversized", int64(len(payload)))
}
writeErrorWithDetails(w, http.StatusInternalServerError, "task result response exceeds the URL-only size limit", map[string]any{
"limit_bytes": maxAsyncMediaResultResponseBytes,
"actual_bytes": len(payload),
}, "result_response_too_large")
return
}
w.Header().Set("Content-Type", "application/json; charset=utf-8")
w.Header().Set("X-Gateway-Response-Format", "url")
w.Header().Set("Deprecation", "true")
w.Header().Set("X-Gateway-Deprecated-Fields", "output_content,result")
w.WriteHeader(http.StatusOK)
_, _ = w.Write(append(payload, '\n'))
if observer != nil {
observer.ObserveResultDelivery("poll_success", int64(len(payload)+1))
}
}
func writeError(w http.ResponseWriter, status int, message string, codes ...string) {
writeErrorWithDetails(w, status, message, nil, codes...)
}
@@ -185,6 +185,8 @@ func (s *Server) createLegacyVolcesVideoGeneration(w http.ResponseWriter, r *htt
// @Security BearerAuth
// @Param taskID path string true "任务 ID"
// @Success 200 {object} EasyAIGeneratedResponse
// @Header 200 {string} X-Gateway-Response-Format "实际结果格式,异步轮询固定为 url"
// @Header 200 {string} Deprecation "output_content 与 result 兼容别名的废弃标记"
// @Failure 404 {object} EasyAIGeneratedResponse
// @Failure 410 {object} EasyAIGeneratedResponse
// @Failure 503 {object} ErrorEnvelope
@@ -214,7 +216,7 @@ func (s *Server) getEasyAITaskResult(w http.ResponseWriter, r *http.Request) {
writeEasyAIAsyncError(w, statusFromRunError(err), err.Error(), nil, clients.ErrorCode(err))
return
}
writeJSON(w, http.StatusOK, easyAITaskResultResponse(task))
writeAsyncMediaResultJSON(w, easyAITaskResultResponse(task), s.billingMetrics)
}
func (s *Server) createVolcesCompatibleTask(r *http.Request, user *auth.User, body map[string]any) (store.GatewayTask, error) {