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:
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user