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
+29 -9
View File
@@ -14,6 +14,7 @@ import (
func main() {
apply := flag.Bool("apply", false, "persist compacted results; default is dry-run")
requireClean := flag.Bool("require-clean", false, "exit non-zero unless a complete dry-run finds no results requiring URL migration")
batchSize := flag.Int("batch-size", 100, "rows per batch, maximum 100")
maxBatches := flag.Int("max-batches", 10, "maximum batches for one invocation")
afterID := flag.String("after-id", "", "resume after this task UUID")
@@ -29,6 +30,10 @@ func main() {
logger.Error("invalid backfill bounds", "batchSize", *batchSize, "maxBatches", *maxBatches)
os.Exit(1)
}
if *apply && *requireClean {
logger.Error("--apply and --require-clean cannot be combined")
os.Exit(1)
}
ctx := context.Background()
db, err := store.Connect(ctx, cfg.DatabaseURL)
@@ -42,8 +47,11 @@ func main() {
cursor := *afterID
scanned := 0
matched := 0
blockingMatched := 0
updated := 0
expired := 0
expiredLocalPlaceholders := 0
complete := false
for batch := 0; batch < *maxBatches; batch++ {
items, err := db.ListTaskBinaryResultBackfillBatch(ctx, cursor, *batchSize)
if err != nil {
@@ -51,26 +59,30 @@ func main() {
os.Exit(1)
}
if len(items) == 0 {
complete = true
break
}
for _, item := range items {
cursor = item.ID
scanned++
if !runner.TaskResultHasInlineBinary(item.Result) {
if !runner.TaskResultNeedsURLMigration(item.Result) {
continue
}
matched++
isExpired := item.FinishedAt.Before(time.Now().Add(-time.Duration(localResultTTLHours(cfg)) * time.Hour))
hasLocalPlaceholder := runner.TaskResultHasLocalPlaceholder(item.Result)
if isExpired && hasLocalPlaceholder {
expiredLocalPlaceholders++
if *apply {
logger.Warn("skip expired local result placeholder without overwriting stored result", "taskId", item.ID)
}
continue
}
blockingMatched++
if !*apply {
continue
}
isExpired := item.FinishedAt.Before(time.Now().Add(-time.Duration(localResultTTLHours(cfg)) * time.Hour))
var persistent map[string]any
var changed bool
if isExpired {
persistent, changed, err = service.CompactExpiredTaskResultForStorage(ctx, item.ID, item.Result)
} else {
persistent, changed, err = service.MaterializeTaskResultForStorage(ctx, item.ID, item.Result)
}
persistent, changed, err := service.MigrateTaskResultToURLs(ctx, item.ID, item.Result)
if err != nil {
logger.Error("materialize historical binary result failed", "taskId", item.ID, "error", err)
os.Exit(1)
@@ -92,6 +104,7 @@ func main() {
}
}
if len(items) < *batchSize {
complete = true
break
}
}
@@ -99,10 +112,17 @@ func main() {
"apply", *apply,
"scanned", scanned,
"matched", matched,
"blockingMatched", blockingMatched,
"updated", updated,
"expired", expired,
"expiredLocalPlaceholders", expiredLocalPlaceholders,
"complete", complete,
"resumeAfterId", cursor,
)
if *requireClean && (!complete || blockingMatched > 0) {
logger.Error("binary result URL migration gate failed", "complete", complete, "blockingMatched", blockingMatched, "expiredLocalPlaceholders", expiredLocalPlaceholders, "resumeAfterId", cursor)
os.Exit(1)
}
}
func localResultTTLHours(cfg config.Config) int {
+13 -1
View File
@@ -5656,6 +5656,16 @@
"description": "OK",
"schema": {
"$ref": "#/definitions/httpapi.EasyAIGeneratedResponse"
},
"headers": {
"Deprecation": {
"type": "string",
"description": "output_content 与 result 兼容别名的废弃标记"
},
"X-Gateway-Response-Format": {
"type": "string",
"description": "实际结果格式,异步轮询固定为 url"
}
}
},
"404": {
@@ -11167,6 +11177,7 @@
}
},
"output_content": {
"description": "OutputContent is a one-release URL-only compatibility alias for Data.",
"type": "array",
"items": {
"$ref": "#/definitions/httpapi.EasyAIMediaOutput"
@@ -11177,6 +11188,7 @@
"example": "/api/v1/ai/result/9f4d8f3d-5f5f-4bb7-a4be-344a9f930e25"
},
"result": {
"description": "Result is a one-release URL-only compatibility alias and is deprecated.",
"type": "object",
"additionalProperties": true
},
@@ -11218,7 +11230,7 @@
"type": "string"
},
"b64_json": {
"description": "B64JSON is retained when result media transfer is disabled.",
"description": "B64JSON is available only to bounded synchronous OpenAI image responses.",
"type": "string"
},
"content": {
+12 -1
View File
@@ -409,6 +409,8 @@ definitions:
type: string
type: array
output_content:
description: OutputContent is a one-release URL-only compatibility alias for
Data.
items:
$ref: '#/definitions/httpapi.EasyAIMediaOutput'
type: array
@@ -417,6 +419,7 @@ definitions:
type: string
result:
additionalProperties: true
description: Result is a one-release URL-only compatibility alias and is deprecated.
type: object
status:
enum:
@@ -446,7 +449,8 @@ definitions:
audio_url:
type: string
b64_json:
description: B64JSON is retained when result media transfer is disabled.
description: B64JSON is available only to bounded synchronous OpenAI image
responses.
type: string
content:
type: string
@@ -8093,6 +8097,13 @@ paths:
responses:
"200":
description: OK
headers:
Deprecation:
description: output_content 与 result 兼容别名的废弃标记
type: string
X-Gateway-Response-Format:
description: 实际结果格式,异步轮询固定为 url
type: string
schema:
$ref: '#/definitions/httpapi.EasyAIGeneratedResponse'
"404":
+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) {
@@ -92,6 +92,8 @@ func mappedError(code string, message string, status int, retryable bool) (Error
break
}
return newError("upstream_unavailable", "The upstream service is temporarily unavailable.", "upstream", http.StatusServiceUnavailable, true, "retry"), true
case "invalid_upstream_result":
return newError("invalid_upstream_result", "The upstream service returned an invalid media result.", "upstream", http.StatusBadGateway, false, "contact_support"), true
case "upload_invalid_response", "invalid_response", "response_too_large", "invalid_upstream_response", "upstream_invalid_response":
return newError("upstream_invalid_response", "The upstream service returned an invalid or incomplete response.", "upstream", http.StatusBadGateway, true, "retry"), true
case "invalid_api_key", "authentication_error", "auth_failed", "missing_credentials", "upstream_auth_failed":
@@ -100,6 +102,16 @@ func mappedError(code string, message string, status int, retryable bool) (Error
return newError("storage_write_failed", "The media asset could not be written to object storage.", "storage", http.StatusServiceUnavailable, true, "retry"), true
case "storage_read_failed", "upload_source_fetch_failed", "upload_source_read_failed":
return newError("storage_read_failed", "The media asset could not be read from object storage.", "storage", http.StatusServiceUnavailable, true, "retry"), true
case "result_url_unavailable":
return newError("result_url_unavailable", "The generated result URL is temporarily unavailable.", "storage", http.StatusServiceUnavailable, true, "retry"), true
case "result_materialization_required":
return newError("result_materialization_required", "The generated result is being migrated to URL storage.", "storage", http.StatusServiceUnavailable, true, "retry"), true
case "response_format_too_large":
return newError("response_format_too_large", "The generated media is too large for a synchronous Base64 response. Use the task result URL instead.", "request", http.StatusRequestEntityTooLarge, false, "use_result_url"), true
case "response_format_capacity_timeout":
return newError("response_format_capacity_timeout", "Synchronous Base64 response capacity is temporarily unavailable.", "gateway", http.StatusServiceUnavailable, true, "retry"), true
case "result_response_too_large":
return newError("result_response_too_large", "The generated result response exceeded the URL-only size limit.", "gateway", http.StatusInternalServerError, false, "contact_support"), true
case "binary_result_expired", "result_expired":
return newError("result_expired", "The generated result has expired and must be submitted again.", "storage", http.StatusGone, false, "resubmit"), true
case "result_unavailable", "request_asset_expired":
+496 -2
View File
@@ -8,6 +8,7 @@ import (
"errors"
"fmt"
"io"
"net/http"
"os"
"path/filepath"
"sort"
@@ -21,6 +22,9 @@ import (
)
const (
// MaxSynchronousInlineResponseBytes bounds the raw media payload restored for
// an explicit synchronous OpenAI-compatible b64_json response.
MaxSynchronousInlineResponseBytes = int64(20 << 20)
localBinaryResultDirName = "results"
localBinaryPlaceholderPrefix = "[GatewayBinary:v1;"
localBinaryGenericBase64MinLength = 4096
@@ -96,6 +100,225 @@ func (s *Service) MaterializeTaskResultForStorage(ctx context.Context, taskID st
return next, hadInline && !TaskResultHasInlineBinary(next), nil
}
// MigrateTaskResultToURLs materializes historical inline payloads and rewrites
// legacy assetRef wrappers into the canonical URL-plus-upload storage shape.
func (s *Service) MigrateTaskResultToURLs(ctx context.Context, taskID string, result map[string]any) (map[string]any, bool, error) {
next := result
changed := false
if TaskResultHasLocalPlaceholder(next) {
restored, restoredChanged, err := s.restoreLocalPlaceholderValue(ctx, taskID, next, 0)
if err != nil {
return nil, false, err
}
mapped, ok := restored.(map[string]any)
if !ok {
return nil, false, &clients.ClientError{Code: "result_binary_not_materialized", Message: "generated result is not a JSON object", StatusCode: http.StatusInternalServerError}
}
next = mapped
changed = restoredChanged
}
if TaskResultHasInlineBinary(next) {
materialized, materializedChanged, err := s.MaterializeTaskResultForStorage(ctx, taskID, next)
if err != nil {
return nil, false, err
}
next = materialized
changed = materializedChanged
}
rewritten, rewrittenChanged := migrateStoredResultURLValue(next, 0)
mapped, ok := rewritten.(map[string]any)
if !ok {
return nil, false, &clients.ClientError{Code: "result_binary_not_materialized", Message: "generated result is not a JSON object", StatusCode: http.StatusInternalServerError}
}
return mapped, changed || rewrittenChanged, nil
}
// TaskResultNeedsURLMigration reports whether a stored result contains inline
// binary, a local placeholder, or the legacy assetRef representation.
func TaskResultNeedsURLMigration(result map[string]any) bool {
if TaskResultHasInlineBinary(result) {
return true
}
return storedResultURLValueNeedsMigration(result, 0)
}
// TaskResultHasLocalPlaceholder reports whether a historical result still
// references a node-local GatewayBinary file.
func TaskResultHasLocalPlaceholder(result map[string]any) bool {
return localBinaryResultHasPlaceholders(result)
}
func (s *Service) restoreLocalPlaceholderValue(ctx context.Context, taskID string, value any, depth int) (any, bool, error) {
if err := ctx.Err(); err != nil {
return nil, false, err
}
if depth >= localBinaryMaxDepth {
return nil, false, &clients.ClientError{Code: "binary_result_corrupted", Message: "stored result exceeds the maximum JSON depth", StatusCode: http.StatusInternalServerError}
}
switch typed := value.(type) {
case map[string]any:
next := make(map[string]any, len(typed))
changed := false
for key, childValue := range typed {
child, childChanged, err := s.restoreLocalPlaceholderValue(ctx, taskID, childValue, depth+1)
if err != nil {
return nil, false, err
}
next[key] = child
changed = changed || childChanged
}
if !changed {
return value, false, nil
}
return next, true, nil
case []any:
next := make([]any, len(typed))
changed := false
for index, childValue := range typed {
child, childChanged, err := s.restoreLocalPlaceholderValue(ctx, taskID, childValue, depth+1)
if err != nil {
return nil, false, err
}
next[index] = child
changed = changed || childChanged
}
if !changed {
return value, false, nil
}
return next, true, nil
case string:
descriptor, ok := parseLocalBinaryPlaceholder(typed)
if !ok {
return value, false, nil
}
payload, err := s.readLocalBinaryResult(taskID, descriptor)
if err != nil {
return nil, false, err
}
encoded := base64.StdEncoding.EncodeToString(payload)
if descriptor.Encoding == "data-uri" {
return "data:" + descriptor.ContentType + ";base64," + encoded, true, nil
}
return encoded, true, nil
default:
return value, false, nil
}
}
func storedResultURLValueNeedsMigration(value any, depth int) bool {
if depth >= localBinaryMaxDepth {
return true
}
switch typed := value.(type) {
case map[string]any:
if _, ok := typed["assetRef"].(map[string]any); ok {
return true
}
for _, child := range typed {
if storedResultURLValueNeedsMigration(child, depth+1) {
return true
}
}
case []any:
for _, child := range typed {
if storedResultURLValueNeedsMigration(child, depth+1) {
return true
}
}
case string:
_, ok := parseLocalBinaryPlaceholder(typed)
return ok
}
return false
}
func migrateStoredResultURLValue(value any, depth int) (any, bool) {
if depth >= localBinaryMaxDepth {
return value, false
}
switch typed := value.(type) {
case map[string]any:
if asset, ok := generatedResultAssetReference(typed); ok {
upload, _ := typed["upload"].(map[string]any)
if upload == nil {
upload = uploadMetadataFromLegacyAsset(asset)
}
accessURL := firstNonEmptyString(stringFromAny(upload["url"]), asset.URL)
next := map[string]any{
"url": accessURL,
"upload": upload,
"assetStorage": map[string]any{
"scene": store.FileStorageSceneImageResult,
"source": stringFromAny(typed["assetStorage"].(map[string]any)["source"]),
"strategy": "migrate_asset_ref",
"contentType": asset.ContentType,
},
}
for _, key := range []string{"type", "mime_type", "width", "height", "duration", "format", "seed", "revised_prompt"} {
if item, exists := typed[key]; exists {
next[key] = item
}
}
if stringFromAny(next["mime_type"]) == "" && asset.ContentType != "" {
next["mime_type"] = asset.ContentType
}
return next, true
}
next := make(map[string]any, len(typed))
changed := false
for key, childValue := range typed {
child, childChanged := migrateStoredResultURLValue(childValue, depth+1)
if resultCanonicalInlineField(key) {
if item, ok := child.(map[string]any); ok && stringFromAny(item["url"]) != "" {
mergeProjectedResultURL(next, item)
changed = true
continue
}
}
next[key] = child
changed = changed || childChanged
}
if !changed {
return value, false
}
return next, true
case []any:
next := make([]any, len(typed))
changed := false
for index, childValue := range typed {
child, childChanged := migrateStoredResultURLValue(childValue, depth+1)
next[index] = child
changed = changed || childChanged
}
if !changed {
return value, false
}
return next, true
default:
return value, false
}
}
func uploadMetadataFromLegacyAsset(asset store.RequestAsset) map[string]any {
upload := map[string]any{
"url": asset.URL,
"objectKey": asset.ObjectKey,
"contentType": asset.ContentType,
"size": asset.ByteSize,
"sha256": asset.SHA256,
"accessScope": asset.AccessScope,
"storageChannel": map[string]any{
"id": asset.StorageChannelID,
"channelKey": asset.StorageChannelKey,
"provider": asset.StorageProvider,
},
}
if asset.ExpiresAt != nil {
upload["objectExpiresAt"] = asset.ExpiresAt.Format(time.RFC3339)
}
return upload
}
// CompactExpiredTaskResultForStorage keeps the historical maintenance API but
// now uses the same object-storage path as live results. New GatewayBinary
// placeholders are never created; their parser remains read-only compatibility.
@@ -325,8 +548,9 @@ func verifyLocalBinaryFile(path string, expectedHash string, expectedSize int64)
return nil
}
// HydrateTaskResult restores placeholders from verified local files. It is only
// used by result/detail/replay endpoints, never by task lists or callbacks.
// HydrateTaskResult restores verified stored payloads only for bounded,
// explicit synchronous inline responses. Asynchronous readers use
// ProjectTaskResultURLs and never call this path.
func (s *Service) HydrateTaskResult(ctx context.Context, taskID string, result map[string]any) (map[string]any, error) {
next, changed, err := s.hydrateLocalBinaryValue(ctx, taskID, result, 0)
if err != nil {
@@ -342,6 +566,244 @@ func (s *Service) HydrateTaskResult(ctx context.Context, taskID string, result m
return mapped, nil
}
// SynchronousInlineResultBytes returns the stored raw byte count that the
// synchronous hydration path would read. It deliberately inspects metadata
// only, so callers can reject oversized responses before any object GET or
// local file read occurs.
func SynchronousInlineResultBytes(result map[string]any) int64 {
return synchronousInlineResultValueBytes(result, 0)
}
func synchronousInlineResultValueBytes(value any, depth int) int64 {
if depth >= localBinaryMaxDepth {
return 0
}
switch typed := value.(type) {
case map[string]any:
if asset, _, ok := generatedResultUploadReference(typed); ok {
storage, _ := typed["assetStorage"].(map[string]any)
if resultCanonicalInlineField(stringFromAny(storage["source"])) {
return asset.ByteSize
}
}
if asset, ok := generatedResultAssetReference(typed); ok {
return asset.ByteSize
}
var total int64
for _, child := range typed {
total += synchronousInlineResultValueBytes(child, depth+1)
if total > MaxSynchronousInlineResponseBytes {
return total
}
}
return total
case []any:
var total int64
for _, child := range typed {
total += synchronousInlineResultValueBytes(child, depth+1)
if total > MaxSynchronousInlineResponseBytes {
return total
}
}
return total
case string:
if descriptor, ok := parseLocalBinaryPlaceholder(typed); ok {
return descriptor.Size
}
}
return 0
}
// ProjectTaskResultURLs returns the public, URL-only representation of a
// stored task result. It may refresh a signed object-storage URL, but it never
// reads object bytes or restores historical inline binary payloads.
func (s *Service) ProjectTaskResultURLs(ctx context.Context, taskID string, result map[string]any) (map[string]any, error) {
next, changed, err := s.projectTaskResultURLValue(ctx, taskID, result, 0)
if err != nil {
return nil, err
}
if !changed {
return result, nil
}
mapped, ok := next.(map[string]any)
if !ok {
return nil, &clients.ClientError{Code: "binary_result_corrupted", Message: "stored result is not a JSON object", StatusCode: http.StatusInternalServerError}
}
return mapped, nil
}
func (s *Service) projectTaskResultURLValue(ctx context.Context, taskID string, value any, depth int) (any, bool, error) {
if err := ctx.Err(); err != nil {
return nil, false, err
}
if depth >= localBinaryMaxDepth {
return nil, false, &clients.ClientError{Code: "binary_result_corrupted", Message: "stored result exceeds the maximum JSON depth", StatusCode: http.StatusInternalServerError}
}
switch typed := value.(type) {
case map[string]any:
if asset, _, ok := generatedResultUploadReference(typed); ok {
accessURL, err := s.resultAssetAccessURL(ctx, asset)
if err != nil {
return nil, false, err
}
next := publicResultURLItem(typed, accessURL, asset.ContentType)
return next, true, nil
}
if asset, ok := generatedResultAssetReference(typed); ok {
accessURL, err := s.resultAssetAccessURL(ctx, asset)
if err != nil {
return nil, false, err
}
next := publicResultURLItem(typed, accessURL, asset.ContentType)
return next, true, nil
}
if _, _, ok := localBufferObjectBytes(typed); ok {
return nil, false, resultMaterializationRequired(taskID)
}
next := make(map[string]any, len(typed))
changed := false
for key, childValue := range typed {
if resultInternalField(key) {
changed = true
continue
}
child, childChanged, err := s.projectTaskResultURLValue(ctx, taskID, childValue, depth+1)
if err != nil {
return nil, false, err
}
if resultCanonicalInlineField(key) {
if item, ok := child.(map[string]any); ok && stringFromAny(item["url"]) != "" {
mergeProjectedResultURL(next, item)
changed = true
continue
}
if raw, ok := child.(string); ok {
if requestAssetStringIsHTTPURL(raw) {
next["url"] = strings.TrimSpace(raw)
changed = true
continue
}
if strings.TrimSpace(raw) != "" {
return nil, false, resultMaterializationRequired(taskID)
}
}
}
if localBinaryKey(key) {
switch inline := child.(type) {
case map[string]any:
if stringFromAny(inline["url"]) != "" {
mergeProjectedResultURL(next, inline)
changed = true
continue
}
case string:
if strings.TrimSpace(inline) != "" {
return nil, false, resultMaterializationRequired(taskID)
}
case []any:
if len(inline) > 0 {
return nil, false, resultMaterializationRequired(taskID)
}
}
}
next[key] = child
changed = changed || childChanged
}
if !changed {
return value, false, nil
}
return next, true, nil
case []any:
next := make([]any, len(typed))
changed := false
for index, childValue := range typed {
child, childChanged, err := s.projectTaskResultURLValue(ctx, taskID, childValue, depth+1)
if err != nil {
return nil, false, err
}
next[index] = child
changed = changed || childChanged
}
if !changed {
return value, false, nil
}
return next, true, nil
case []byte:
if len(typed) > 0 {
return nil, false, resultMaterializationRequired(taskID)
}
return value, false, nil
case string:
if _, ok := parseLocalBinaryPlaceholder(typed); ok {
return nil, false, resultMaterializationRequired(taskID)
}
if strings.HasPrefix(strings.ToLower(strings.TrimSpace(typed)), "data:") {
return nil, false, resultMaterializationRequired(taskID)
}
return value, false, nil
default:
return value, false, nil
}
}
func (s *Service) resultAssetAccessURL(ctx context.Context, asset store.RequestAsset) (string, error) {
accessURL, err := s.requestAssetAccessURL(ctx, asset)
if err != nil || strings.TrimSpace(accessURL) == "" {
message := "stored result URL is unavailable"
if err != nil {
message = err.Error()
}
return "", &clients.ClientError{Code: "result_url_unavailable", Message: message, StatusCode: http.StatusServiceUnavailable, Retryable: true}
}
return accessURL, nil
}
func resultMaterializationRequired(taskID string) error {
return &clients.ClientError{
Code: "result_materialization_required",
Message: "stored result must be migrated to object storage before it can be returned",
Details: map[string]any{"task_id": strings.TrimSpace(taskID)},
StatusCode: http.StatusServiceUnavailable,
Retryable: true,
}
}
func publicResultURLItem(source map[string]any, accessURL string, contentType string) map[string]any {
next := map[string]any{"url": accessURL}
for _, key := range []string{"type", "mime_type", "mimeType", "content_type", "contentType", "width", "height", "duration", "format", "seed", "revised_prompt"} {
if value, ok := source[key]; ok && value != nil {
next[key] = value
}
}
if stringFromAny(next["mime_type"]) == "" && strings.TrimSpace(contentType) != "" {
next["mime_type"] = strings.TrimSpace(contentType)
}
return next
}
func mergeProjectedResultURL(target map[string]any, item map[string]any) {
for key, value := range item {
if _, exists := target[key]; !exists || key == "url" {
target[key] = value
}
}
}
func resultInternalField(key string) bool {
switch normalizeLocalBinaryKey(key) {
case "assetref", "assetstorage", "upload", "raw", "rawdata", "rawresponse", "providerpayload", "providerresponse", "providerraw", "upstreamresponse", "thinkingbytes", "thoughtsignature", "signaturebuffer":
return true
default:
return false
}
}
func resultCanonicalInlineField(key string) bool {
normalized := normalizeLocalBinaryKey(key)
return normalized == "b64json" || normalized == "base64" || normalized == "b64" ||
normalized == "datauri" || strings.Contains(normalized, "base64") || strings.HasSuffix(normalized, "b64")
}
func (s *Service) hydrateLocalBinaryValue(ctx context.Context, taskID string, value any, depth int) (any, bool, error) {
if err := ctx.Err(); err != nil {
return nil, false, err
@@ -374,6 +836,31 @@ func (s *Service) hydrateLocalBinaryValue(ctx context.Context, taskID string, va
}
typed = next
refreshedAccessURL = true
storage, _ := typed["assetStorage"].(map[string]any)
sourceKey := strings.TrimSpace(stringFromAny(storage["source"]))
if resultCanonicalInlineField(sourceKey) && asset.SHA256 != "" && asset.ByteSize > 0 {
payload, contentType, err := s.readGeneratedResultAsset(ctx, asset)
if err != nil {
return nil, false, err
}
encoded := base64.StdEncoding.EncodeToString(payload)
inline := make(map[string]any, len(typed))
for key, item := range typed {
if resultInternalField(key) || key == "url" || key == "image_url" || key == "video_url" || key == "audio_url" {
continue
}
inline[key] = item
}
if generatedResultAssetUsesDataURI(typed) {
inline[sourceKey] = "data:" + contentType + ";base64," + encoded
} else {
inline[sourceKey] = encoded
}
if stringFromAny(inline["mime_type"]) == "" {
inline["mime_type"] = contentType
}
return inline, true, nil
}
}
if ref, ok := generatedResultAssetReference(typed); ok {
payload, contentType, err := s.readGeneratedResultAsset(ctx, ref)
@@ -389,6 +876,10 @@ func (s *Service) hydrateLocalBinaryValue(ctx context.Context, taskID string, va
next := make(map[string]any, len(typed))
changed := refreshedAccessURL
for key, childValue := range typed {
if resultInternalField(key) {
changed = true
continue
}
child, childChanged, err := s.hydrateLocalBinaryValue(ctx, taskID, childValue, depth+1)
if err != nil {
return nil, false, err
@@ -446,6 +937,9 @@ func generatedResultUploadReference(value map[string]any) (store.RequestAsset, m
return store.RequestAsset{}, nil, false
}
return store.RequestAsset{
SHA256: strings.ToLower(strings.TrimSpace(stringFromAny(upload["sha256"]))),
ContentType: stringFromAny(upload["contentType"]),
ByteSize: int64(floatFromAny(upload["size"])),
URL: stringFromAny(upload["url"]),
StorageProvider: stringFromAny(channel["provider"]),
StorageChannelID: stringFromAny(channel["id"]),
@@ -8,6 +8,7 @@ import (
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"net/http"
"net/http/httptest"
"os"
@@ -209,6 +210,181 @@ func TestHydrateGeneratedResultRefreshesPrivateObjectURL(t *testing.T) {
}
}
func TestHydrateCanonicalUploadedResultForExplicitSynchronousBase64(t *testing.T) {
payload := []byte("canonical uploaded image")
digest := sha256.Sum256(payload)
getCount := 0
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method == http.MethodGet {
getCount++
_, _ = w.Write(payload)
return
}
w.WriteHeader(http.StatusOK)
}))
defer server.Close()
cfg := config.Config{
MediaOSSDirectEnabled: true, MediaOSSEndpoint: server.URL, MediaOSSBucket: "media-bucket",
MediaOSSAccessKeyID: "access-id", MediaOSSAccessKeySecret: "access-secret", MediaOSSObjectPrefix: "media",
}
service := &Service{cfg: cfg, directOSS: newDirectOSSUploader(cfg)}
result := map[string]any{"thinking_bytes": strings.Repeat("opaque", 1024), "thought_signature": "signature", "data": []any{map[string]any{
"type": "image", "url": "https://expired.example/result.png", "mime_type": "image/png",
"assetStorage": map[string]any{"scene": store.FileStorageSceneImageResult, "source": "b64_json"},
"upload": map[string]any{
"url": "https://expired.example/result.png", "objectKey": "media/image_result/hash.png", "accessScope": "private",
"sha256": hex.EncodeToString(digest[:]), "size": len(payload), "contentType": "image/png",
"storageChannel": map[string]any{"channelKey": "environment-direct-oss", "provider": "aliyun_oss"},
},
}}}
projected, err := service.ProjectTaskResultURLs(t.Context(), "task-sync-b64", result)
if err != nil {
t.Fatal(err)
}
projectedItem := projected["data"].([]any)[0].(map[string]any)
if stringFromAny(projectedItem["url"]) == "" || getCount != 0 {
t.Fatalf("URL projection read object: item=%#v getCount=%d", projectedItem, getCount)
}
if projected["thinking_bytes"] != nil || projected["thought_signature"] != nil {
t.Fatalf("provider metadata leaked: %#v", projected)
}
hydrated, err := service.HydrateTaskResult(t.Context(), "task-sync-b64", result)
if err != nil {
t.Fatal(err)
}
item := hydrated["data"].([]any)[0].(map[string]any)
if got := stringFromAny(item["b64_json"]); got != base64.StdEncoding.EncodeToString(payload) {
t.Fatalf("Base64=%q", got)
}
if item["url"] != nil || item["upload"] != nil || getCount != 1 {
t.Fatalf("unexpected hydrated item=%#v getCount=%d", item, getCount)
}
if hydrated["thinking_bytes"] != nil || hydrated["thought_signature"] != nil {
t.Fatalf("provider metadata leaked into synchronous response: %#v", hydrated)
}
}
func TestSynchronousInlineResultBytesUsesMetadataBeforeObjectRead(t *testing.T) {
result := map[string]any{"data": []any{
map[string]any{
"assetStorage": map[string]any{"scene": store.FileStorageSceneImageResult, "source": "b64_json"},
"upload": map[string]any{
"objectKey": "media/image_result/large.png", "sha256": strings.Repeat("a", 64),
"size": MaxSynchronousInlineResponseBytes + 1, "contentType": "image/png",
"storageChannel": map[string]any{"channelKey": "environment-direct-oss"},
},
},
}}
if got := SynchronousInlineResultBytes(result); got != MaxSynchronousInlineResponseBytes+1 {
t.Fatalf("stored bytes=%d", got)
}
}
func TestProjectTaskResultURLsConvertsLegacyAssetWithoutReadingObject(t *testing.T) {
payload := []byte("must never be downloaded")
digest := sha256.Sum256(payload)
getCount := 0
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method == http.MethodGet {
getCount++
}
http.Error(w, "object read is forbidden", http.StatusInternalServerError)
}))
defer server.Close()
service := &Service{}
result := map[string]any{"data": []any{map[string]any{
"b64_json": map[string]any{
"assetRef": map[string]any{
"sha256": hex.EncodeToString(digest[:]), "contentType": "image/png", "size": len(payload), "url": server.URL + "/result.png",
},
"assetStorage": map[string]any{"scene": store.FileStorageSceneImageResult, "source": "b64_json"},
},
}}}
projected, err := service.ProjectTaskResultURLs(t.Context(), "task-url-only", result)
if err != nil {
t.Fatal(err)
}
item := projected["data"].([]any)[0].(map[string]any)
if got := stringFromAny(item["url"]); got != server.URL+"/result.png" {
t.Fatalf("projected URL=%q", got)
}
if item["b64_json"] != nil || item["assetRef"] != nil || item["assetStorage"] != nil || item["upload"] != nil {
t.Fatalf("internal or inline fields leaked: %#v", item)
}
if getCount != 0 {
t.Fatalf("projector downloaded object %d time(s)", getCount)
}
}
func TestProjectTaskResultURLsRejectsHistoricalInlinePayload(t *testing.T) {
service := &Service{}
_, err := service.ProjectTaskResultURLs(t.Context(), "task-inline", map[string]any{
"data": []any{map[string]any{"b64_json": base64.StdEncoding.EncodeToString([]byte("inline"))}},
})
assertClientErrorCode(t, err, "result_materialization_required")
}
func TestMigrateTaskResultToURLsRewritesLegacyAssetReference(t *testing.T) {
payload := []byte("legacy")
digest := sha256.Sum256(payload)
service := &Service{}
result := map[string]any{"data": []any{map[string]any{
"b64_json": map[string]any{
"assetRef": map[string]any{
"sha256": hex.EncodeToString(digest[:]), "contentType": "image/png", "size": len(payload), "url": "https://cdn.example/result.png",
},
"assetStorage": map[string]any{"scene": store.FileStorageSceneImageResult, "source": "b64_json"},
},
}}}
migrated, changed, err := service.MigrateTaskResultToURLs(t.Context(), "task-legacy", result)
if err != nil {
t.Fatal(err)
}
if !changed || TaskResultNeedsURLMigration(migrated) {
t.Fatalf("legacy result was not fully migrated: %#v", migrated)
}
item := migrated["data"].([]any)[0].(map[string]any)
if stringFromAny(item["url"]) != "https://cdn.example/result.png" || item["upload"] == nil || item["b64_json"] != nil {
t.Fatalf("unexpected migrated result: %#v", item)
}
}
func TestMigrateTaskResultToURLsUploadsActiveLocalPlaceholder(t *testing.T) {
payload := []byte("historical local image")
digest := sha256.Sum256(payload)
putCount := 0
storageServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method == http.MethodPut {
putCount++
w.WriteHeader(http.StatusOK)
return
}
http.Error(w, "unexpected method", http.StatusMethodNotAllowed)
}))
defer storageServer.Close()
service := newLocalBinaryTestService(t)
service.directOSS = &directOSSUploader{
endpoint: storageServer.URL, bucket: "bucket", accessKeyID: "access-id", accessKeySecret: "access-secret", objectPrefix: "media",
}
writeHistoricalLocalBinaryFixture(t, service, "task-local-migrate", payload)
placeholder := fmt.Sprintf("%ssha256=%s;bytes=%d;mime=image/png;encoding=base64]", localBinaryPlaceholderPrefix, hex.EncodeToString(digest[:]), len(payload))
result := map[string]any{"data": []any{map[string]any{"b64_json": placeholder}}}
migrated, changed, err := service.MigrateTaskResultToURLs(t.Context(), "task-local-migrate", result)
if err != nil {
t.Fatal(err)
}
if !changed || TaskResultNeedsURLMigration(migrated) || putCount != 1 {
t.Fatalf("placeholder migration changed=%t putCount=%d result=%#v", changed, putCount, migrated)
}
}
func TestHydrateLocalBinaryResultReturnsExpiredAndCorruptedErrors(t *testing.T) {
service := newLocalBinaryTestService(t)
service.cfg.LocalResultTTLHours = 1
+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)
}
+69 -49
View File
@@ -50,6 +50,13 @@ type generatedAssetDecision struct {
StripKeys []string
}
type generatedAssetUploadResult struct {
upload map[string]any
contentType string
kind string
strategy string
}
type generatedInlineAsset struct {
Bytes []byte
ContentType string
@@ -150,6 +157,9 @@ func (s *Service) uploadGeneratedAssets(ctx context.Context, taskID string, task
return nil, err
}
decisions[index] = decision
if _, mediaURL := mediaURLSourceFromItem(item); mediaURL != "" && decision.URL == nil {
s.observeResultStorage("upstream_url")
}
if decision.Inline != nil || decision.URL != nil {
needsUpload = true
}
@@ -175,6 +185,7 @@ func (s *Service) uploadGeneratedAssets(ctx context.Context, taskID string, task
next[key] = value
}
nextData := make([]any, 0, len(data))
uploadCache := make(map[string]generatedAssetUploadResult)
for index, rawItem := range data {
item, _ := rawItem.(map[string]any)
if item == nil {
@@ -197,7 +208,15 @@ func (s *Service) uploadGeneratedAssets(ctx context.Context, taskID string, task
var contentType string
var err error
if decision.Inline != nil {
upload, contentType, kind, strategy, err = s.uploadGeneratedAsset(ctx, taskID, decision.Inline, index, channels)
cacheKey := generatedInlineAssetCacheKey(decision.Inline)
if cached, ok := uploadCache[cacheKey]; ok {
upload, contentType, kind, strategy = cached.upload, cached.contentType, cached.kind, cached.strategy
} else {
upload, contentType, kind, strategy, err = s.uploadGeneratedAsset(ctx, taskID, decision.Inline, index, channels)
if err == nil {
uploadCache[cacheKey] = generatedAssetUploadResult{upload: upload, contentType: contentType, kind: kind, strategy: strategy}
}
}
sourceKey = decision.Inline.SourceKey
} else {
upload, contentType, kind, strategy, err = s.uploadGeneratedURLAsset(ctx, taskID, decision.URL, index, channels, acceptanceEmulatorBaseURL)
@@ -233,9 +252,6 @@ func (s *Service) uploadGeneratedAssets(ctx context.Context, taskID string, task
if contentType != "" && stringFromAny(merged["mime_type"]) == "" {
merged["mime_type"] = contentType
}
if decision.Inline != nil && strings.TrimSpace(sourceKey) != "" {
merged[sourceKey] = generatedRawMediaReference(decision.Inline, upload, contentType, kind, strategy)
}
}
nextData = append(nextData, merged)
}
@@ -255,6 +271,15 @@ func (s *Service) uploadGeneratedAssets(ctx context.Context, taskID string, task
return s.finalizeGeneratedAssets(ctx, taskID, taskKind, next, policy, channels, channelsLoaded, len(nextData))
}
func generatedInlineAssetCacheKey(asset *generatedInlineAsset) string {
if asset == nil {
return ""
}
digest := sha256.Sum256(asset.Bytes)
contentType := resolvedGeneratedAssetContentType(asset.ContentType, asset.Kind, asset.Bytes)
return hex.EncodeToString(digest[:]) + ":" + contentType
}
func generatedAssetUploadPolicyForAcceptanceRun(policy generatedAssetUploadPolicy, acceptanceRunID string) generatedAssetUploadPolicy {
if strings.TrimSpace(acceptanceRunID) != "" {
policy.UploadURLMedia = true
@@ -274,7 +299,7 @@ func (s *Service) finalizeGeneratedAssets(
) (map[string]any, error) {
redactGeneratedResultRawData(result)
if !TaskResultHasInlineBinary(result) {
return result, nil
return canonicalStoredResultURLs(result), nil
}
next := result
if policy.UploadInlineMedia {
@@ -304,7 +329,7 @@ func (s *Service) finalizeGeneratedAssets(
}
}
if !TaskResultHasInlineBinary(next) {
return next, nil
return canonicalStoredResultURLs(next), nil
}
diagnostics := taskResultInlineBinaryDiagnostics(next)
if s.logger != nil {
@@ -322,6 +347,18 @@ func (s *Service) finalizeGeneratedAssets(
}
}
func canonicalStoredResultURLs(result map[string]any) map[string]any {
next, changed := migrateStoredResultURLValue(result, 0)
if !changed {
return result
}
mapped, ok := next.(map[string]any)
if !ok {
return result
}
return mapped
}
func generatedRawValueHasInlineMedia(value any, key string, siblings map[string]any) bool {
switch typed := value.(type) {
case map[string]any:
@@ -448,42 +485,9 @@ func generatedRawInlineMediaAsset(key string, value string, siblings map[string]
}
func generatedRawMediaReference(asset *generatedInlineAsset, upload map[string]any, contentType string, kind string, strategy string) map[string]any {
digest := sha256.Sum256(asset.Bytes)
urlValue := stringFromAny(upload["url"])
ref := map[string]any{
"sha256": hex.EncodeToString(digest[:]),
"contentType": contentType,
"size": len(asset.Bytes),
}
if urlValue != "" {
ref["url"] = urlValue
}
if fileName := stringFromAny(upload["fileName"]); fileName != "" {
ref["fileName"] = fileName
}
if expiresAt := stringFromAny(upload["expiresAt"]); expiresAt != "" {
ref["expiresAt"] = expiresAt
}
if channel, ok := upload["storageChannel"].(map[string]any); ok {
if provider := stringFromAny(channel["provider"]); provider != "" {
ref["storageProvider"] = provider
}
if id := stringFromAny(channel["id"]); id != "" {
ref["storageChannelId"] = id
}
if key := stringFromAny(channel["channelKey"]); key != "" {
ref["storageChannelKey"] = key
}
}
if objectKey := stringFromAny(upload["objectKey"]); objectKey != "" {
ref["objectKey"] = objectKey
}
if accessScope := stringFromAny(upload["accessScope"]); accessScope != "" {
ref["accessScope"] = accessScope
}
out := map[string]any{
"assetRef": ref,
"upload": upload,
"upload": upload,
"assetStorage": map[string]any{
"scene": store.FileStorageSceneImageResult,
"source": asset.SourceKey,
@@ -497,6 +501,9 @@ func generatedRawMediaReference(asset *generatedInlineAsset, upload map[string]a
if kind != "" {
out["type"] = kind
}
if contentType != "" {
out["mime_type"] = contentType
}
return out
}
@@ -713,6 +720,9 @@ func (s *Service) uploadGeneratedAsset(ctx context.Context, taskID string, asset
return nil, "", "", "", &clients.ClientError{Code: "storage_write_failed", Message: "no enabled object storage channel", StatusCode: http.StatusServiceUnavailable, Retryable: true}
}
upload, err := s.uploadFileWithFailover(ctx, payload, channels)
if err == nil {
s.observeResultStorage("uploaded")
}
return upload, contentType, kind, "upload_inline_media", err
}
@@ -734,6 +744,9 @@ func (s *Service) uploadGeneratedURLAsset(ctx context.Context, taskID string, as
return nil, "", "", "", &clients.ClientError{Code: "storage_write_failed", Message: "no enabled object storage channel", StatusCode: http.StatusServiceUnavailable, Retryable: true}
}
upload, err := s.uploadFileWithFailover(ctx, uploadPayload, channels)
if err == nil {
s.observeResultStorage("uploaded")
}
return upload, contentType, kind, "upload_url_media", err
}
@@ -984,7 +997,7 @@ func (s *Service) uploadFileWithFailover(ctx context.Context, payload FileUpload
func storageFailureAllowsFailover(channel store.FileStorageChannel, err error) bool {
code := strings.ToLower(strings.TrimSpace(clients.ErrorCode(err)))
switch code {
case "upload_source_too_large", "upload_decode_failed", "invalid_multipart_file", "invalid_multipart_image", "invalid_multipart_audio":
case "upload_source_too_large", "upload_decode_failed", "invalid_upstream_result", "invalid_multipart_file", "invalid_multipart_image", "invalid_multipart_audio":
return false
}
var clientErr *clients.ClientError
@@ -1120,6 +1133,12 @@ func stripDataURLPrefix(value string) string {
func generatedAssetDecisionForItem(taskKind string, item map[string]any, policy generatedAssetUploadPolicy) (generatedAssetDecision, error) {
decision := generatedAssetDecision{}
for _, key := range mediaURLCandidateKeys() {
value := strings.TrimSpace(stringFromAny(item[key]))
if value != "" && strings.Contains(value, "://") && !mediaURLString(value) {
return decision, &clients.ClientError{Code: "invalid_upstream_result", Message: "generated media URL must use http or https", StatusCode: http.StatusBadGateway, Retryable: false}
}
}
urlKey, mediaURL := mediaURLSourceFromItem(item)
if mediaURL != "" {
if !policy.UploadURLMedia {
@@ -1225,7 +1244,7 @@ func inlineMediaPayloadFromString(value string, strictBase64 bool) ([]byte, stri
}
payload, err := decodeBase64Payload(encoded)
if err != nil {
return nil, "", false, &clients.ClientError{Code: "upload_decode_failed", Message: err.Error(), Retryable: false}
return nil, "", false, &clients.ClientError{Code: "invalid_upstream_result", Message: err.Error(), StatusCode: http.StatusBadGateway, Retryable: false}
}
return payload, contentType, true, nil
}
@@ -1235,7 +1254,7 @@ func inlineMediaPayloadFromString(value string, strictBase64 bool) ([]byte, stri
payload, err := decodeBase64Payload(raw)
if err != nil {
if strictBase64 {
return nil, "", false, &clients.ClientError{Code: "upload_decode_failed", Message: err.Error(), Retryable: false}
return nil, "", false, &clients.ClientError{Code: "invalid_upstream_result", Message: err.Error(), StatusCode: http.StatusBadGateway, Retryable: false}
}
return nil, "", false, nil
}
@@ -1245,7 +1264,7 @@ func inlineMediaPayloadFromString(value string, strictBase64 bool) ([]byte, stri
func parseBase64DataURL(value string) (string, string, bool, error) {
prefix, payload, ok := strings.Cut(value, ",")
if !ok {
return "", "", false, &clients.ClientError{Code: "upload_decode_failed", Message: "invalid data URL media payload", Retryable: false}
return "", "", false, &clients.ClientError{Code: "invalid_upstream_result", Message: "invalid data URL media payload", StatusCode: http.StatusBadGateway, Retryable: false}
}
meta := strings.TrimPrefix(prefix, "data:")
meta = strings.TrimPrefix(meta, "DATA:")
@@ -1259,7 +1278,7 @@ func parseBase64DataURL(value string) (string, string, bool, error) {
}
}
if !isBase64 {
return "", "", false, &clients.ClientError{Code: "upload_decode_failed", Message: "data URL media payload is not base64 encoded", Retryable: false}
return "", "", false, &clients.ClientError{Code: "invalid_upstream_result", Message: "data URL media payload is not base64 encoded", StatusCode: http.StatusBadGateway, Retryable: false}
}
return contentType, payload, true, nil
}
@@ -1456,10 +1475,11 @@ func mediaURLString(value string) bool {
if strings.HasPrefix(lower, "data:") {
return false
}
return strings.HasPrefix(lower, "http://") ||
strings.HasPrefix(lower, "https://") ||
strings.HasPrefix(lower, "/") ||
strings.Contains(lower, "://")
if strings.HasPrefix(lower, "/") {
return true
}
parsed, err := url.Parse(raw)
return err == nil && parsed.User == nil && parsed.Host != "" && (parsed.Scheme == "http" || parsed.Scheme == "https")
}
func mediaContentTypeFromItem(item map[string]any) string {
+64 -14
View File
@@ -55,6 +55,43 @@ func TestGeneratedAssetDecisionUploadsInlineImageBase64(t *testing.T) {
}
}
func TestUploadGeneratedAssetsReusesDuplicateSHAWithinTask(t *testing.T) {
putCount := 0
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPut {
http.Error(w, "unexpected method", http.StatusMethodNotAllowed)
return
}
putCount++
w.WriteHeader(http.StatusOK)
}))
defer server.Close()
service := &Service{}
service.directOSS = &directOSSUploader{
endpoint: server.URL, bucket: "bucket", accessKeyID: "access-id", accessKeySecret: "access-secret", objectPrefix: "media",
}
payload := append([]byte{0x89, 'P', 'N', 'G', 0x0d, 0x0a, 0x1a, 0x0a}, bytes.Repeat([]byte{0}, 32)...)
encoded := base64.StdEncoding.EncodeToString(payload)
result := map[string]any{"data": []any{
map[string]any{"b64_json": encoded, "mime_type": "image/png"},
map[string]any{"b64_json": encoded, "mime_type": "image/png"},
}}
stored, err := service.uploadGeneratedAssets(t.Context(), "task-duplicate", "images.generations", "", result)
if err != nil {
t.Fatal(err)
}
items := stored["data"].([]any)
first := items[0].(map[string]any)
second := items[1].(map[string]any)
if putCount != 1 || stringFromAny(first["url"]) == "" || first["url"] != second["url"] {
t.Fatalf("duplicate SHA was not reused: puts=%d first=%#v second=%#v", putCount, first, second)
}
if TaskResultHasInlineBinary(stored) || first["b64_json"] != nil || second["b64_json"] != nil {
t.Fatalf("inline payload remained after deduplicated upload: %#v", stored)
}
}
func TestMediaResultMaterializationConcurrencyIsBounded(t *testing.T) {
service := New(config.Config{MediaMaterializationConcurrency: 1}, nil, nil)
result := map[string]any{
@@ -227,6 +264,15 @@ func TestGeneratedAssetDecisionUploadsURLWhenPolicyUploadAll(t *testing.T) {
}
}
func TestGeneratedAssetDecisionRejectsUnsupportedURLScheme(t *testing.T) {
_, err := generatedAssetDecisionForItem("images.generations", map[string]any{
"url": "ftp://files.example/result.png", "type": "image",
}, defaultGeneratedAssetUploadPolicy())
if clients.ErrorCode(err) != "invalid_upstream_result" {
t.Fatalf("unexpected error: %v", err)
}
}
func TestGeneratedAssetUploadPolicyFromName(t *testing.T) {
tests := []struct {
name string
@@ -329,13 +375,15 @@ func TestFinalizeGeneratedAssetsUploadsNestedInlineBinaryUnderDefaultPolicy(t *t
t.Fatal("finalized result still contains inline binary")
}
nested := finalized["provider_result"].(map[string]any)
reference, ok := nested["binary_data_base64"].(map[string]any)
if !ok {
t.Fatalf("nested binary should be replaced by an asset reference: %+v", nested)
if _, exists := nested["binary_data_base64"]; exists {
t.Fatalf("nested Base64 field was retained: %+v", nested)
}
if urlValue := stringFromAny(reference["url"]); !strings.HasPrefix(urlValue, "https://cdn.example.com/media/image_result/") {
if urlValue := stringFromAny(nested["url"]); !strings.HasPrefix(urlValue, "https://cdn.example.com/media/image_result/") {
t.Fatalf("unexpected object storage URL: %s", urlValue)
}
if nested["upload"] == nil {
t.Fatalf("nested upload metadata was not retained: %+v", nested)
}
}
func TestFinalizeGeneratedAssetsMaterializesDetectedMediaAndKeepsOpaqueMetadata(t *testing.T) {
@@ -368,7 +416,7 @@ func TestFinalizeGeneratedAssetsMaterializesDetectedMediaAndKeepsOpaqueMetadata(
t.Fatalf("finalized result still contains generated media: %#v", finalized)
}
reference, ok := finalized["provider_payload"].(map[string]any)
if !ok || reference["assetRef"] == nil || reference["upload"] == nil {
if !ok || reference["url"] == nil || reference["upload"] == nil || reference["assetRef"] != nil {
t.Fatalf("detected media was not objectified: %#v", finalized["provider_payload"])
}
if finalized["thought_signature"] != opaque {
@@ -407,9 +455,11 @@ func TestFinalizeGeneratedAssetsMaterializesGeminiImageData(t *testing.T) {
}
data := finalized["data"].([]any)
item := data[0].(map[string]any)
reference, ok := item["b64_json"].(map[string]any)
if !ok || reference["assetRef"] == nil || reference["upload"] == nil {
t.Fatalf("Gemini b64_json was not replaced by an object reference: %#v", item)
if _, exists := item["b64_json"]; exists {
t.Fatalf("Gemini b64_json was retained: %#v", item)
}
if stringFromAny(item["url"]) == "" || item["upload"] == nil || item["assetRef"] != nil {
t.Fatalf("Gemini b64_json was not replaced by a URL result: %#v", item)
}
}
@@ -474,7 +524,7 @@ func TestUploadGeneratedAudioFailsWithoutObjectStorage(t *testing.T) {
}
}
func TestUploadGeneratedRawMediaValueReplacesGeminiInlineDataWithAssetRef(t *testing.T) {
func TestUploadGeneratedRawMediaValueReplacesGeminiInlineDataWithURL(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(http.StatusOK) }))
defer server.Close()
service := &Service{}
@@ -516,9 +566,9 @@ func TestUploadGeneratedRawMediaValueReplacesGeminiInlineDataWithAssetRef(t *tes
if !ok {
t.Fatalf("inlineData.data should be an asset reference, got %+v", inlineData["data"])
}
ref, _ := data["assetRef"].(map[string]any)
if ref["sha256"] == "" || ref["contentType"] != "image/png" || ref["size"] != len(payload) {
t.Fatalf("unexpected asset ref: %+v", ref)
upload, _ := data["upload"].(map[string]any)
if upload["sha256"] == "" || upload["contentType"] != "image/png" || upload["size"] != len(payload) || data["assetRef"] != nil {
t.Fatalf("unexpected URL storage metadata: %+v", data)
}
if urlValue := stringFromAny(data["url"]); !strings.HasPrefix(urlValue, "https://cdn.example.com/media/image_result/") || !strings.HasSuffix(urlValue, ".png") {
t.Fatalf("unexpected raw media URL: %s", urlValue)
@@ -528,7 +578,7 @@ func TestUploadGeneratedRawMediaValueReplacesGeminiInlineDataWithAssetRef(t *tes
}
}
func TestUploadGeneratedRawMediaValueReplacesBufferAndBytesWithAssetRefs(t *testing.T) {
func TestUploadGeneratedRawMediaValueReplacesBufferAndBytesWithURLs(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(http.StatusOK) }))
defer server.Close()
service := &Service{}
@@ -551,7 +601,7 @@ func TestUploadGeneratedRawMediaValueReplacesBufferAndBytesWithAssetRefs(t *test
next := uploaded.(map[string]any)
for _, key := range []string{"buffer", "audio_bytes", "direct"} {
item, ok := next[key].(map[string]any)
if !ok || item["assetRef"] == nil || item["upload"] == nil {
if !ok || item["url"] == nil || item["upload"] == nil || item["assetRef"] != nil {
t.Fatalf("%s was not objectified: %#v", key, next[key])
}
}
@@ -109,6 +109,11 @@ type Metrics struct {
storageChannelFailovers atomic.Uint64
storageAllChannelsFailed atomic.Uint64
storageObjectifiedBytes atomic.Uint64
resultSourceUpstreamURL atomic.Uint64
resultSourceUploaded atomic.Uint64
resultPollResponses atomic.Uint64
resultPollResponseBytes atomic.Uint64
resultPollOversized atomic.Uint64
taskAdmissionWaitBuckets [11]atomic.Uint64
taskAdmissionWaitMicros atomic.Uint64
}
@@ -345,6 +350,27 @@ func (m *Metrics) ObserveObjectStorage(event string, provider string, bytes int6
}
}
func (m *Metrics) ObserveResultStorage(source string) {
switch strings.ToLower(strings.TrimSpace(source)) {
case "upstream_url":
m.resultSourceUpstreamURL.Add(1)
case "uploaded":
m.resultSourceUploaded.Add(1)
}
}
func (m *Metrics) ObserveResultDelivery(event string, bytes int64) {
switch strings.ToLower(strings.TrimSpace(event)) {
case "poll_success":
m.resultPollResponses.Add(1)
if bytes > 0 {
m.resultPollResponseBytes.Add(uint64(bytes))
}
case "poll_oversized":
m.resultPollOversized.Add(1)
}
}
func (m *Metrics) ObserveTaskAdmissionWait(wait time.Duration) {
if wait < 0 {
wait = 0
@@ -561,6 +587,13 @@ func (m *Metrics) Handler(provider MetricsSnapshotProvider, issuer, audience str
plainCounter(w, "easyai_gateway_storage_channel_failovers_total", "Switches to the next configured storage channel.", m.storageChannelFailovers.Load())
plainCounter(w, "easyai_gateway_storage_all_channels_failed_total", "Storage writes where every eligible channel failed.", m.storageAllChannelsFailed.Load())
plainCounter(w, "easyai_gateway_storage_objectified_bytes_total", "Binary bytes successfully written through storage channels.", m.storageObjectifiedBytes.Load())
outcomeCounters(w, "easyai_gateway_result_storage_total", "Generated media results by canonical storage source.", []outcomeValue{
{"upstream_url", m.resultSourceUpstreamURL.Load()},
{"uploaded", m.resultSourceUploaded.Load()},
})
plainCounter(w, "easyai_gateway_result_poll_responses_total", "Successful URL-only task result responses.", m.resultPollResponses.Load())
plainCounter(w, "easyai_gateway_result_poll_response_bytes_total", "Bytes written by successful URL-only task result responses.", m.resultPollResponseBytes.Load())
plainCounter(w, "easyai_gateway_result_poll_oversized_total", "Task result responses rejected by the URL-only size gate.", m.resultPollOversized.Load())
publicErrorCounters(w, publicerror.MetricSnapshot())
platformModelRateLimitUtilizationGauges(w, modelRateLimits)
plainGauge(w, "easyai_gateway_postgres_pool_max_connections", "Maximum PostgreSQL connections in this process pool.", int64(postgresPool.MaxConnections))
@@ -57,6 +57,10 @@ func TestMetricsExposeBoundedOutcomesAndState(t *testing.T) {
metrics.ObserveObjectStorage("retry", "s3", 0, 0)
metrics.ObserveObjectStorage("failover", "s3", 0, 0)
metrics.ObserveObjectStorage("all_failed", "", 0, 0)
metrics.ObserveResultStorage("upstream_url")
metrics.ObserveResultStorage("uploaded")
metrics.ObserveResultDelivery("poll_success", 512)
metrics.ObserveResultDelivery("poll_oversized", 70<<10)
metrics.ObserveAsyncWorkerResize("success")
metrics.ObserveConcurrencyLeaseRenewal("success")
metrics.ObserveConcurrencyLeaseRenewal("lost")
@@ -117,6 +121,11 @@ func TestMetricsExposeBoundedOutcomesAndState(t *testing.T) {
`easyai_gateway_storage_channel_failovers_total 1`,
`easyai_gateway_storage_all_channels_failed_total 1`,
`easyai_gateway_storage_objectified_bytes_total 128`,
`easyai_gateway_result_storage_total{outcome="upstream_url"} 1`,
`easyai_gateway_result_storage_total{outcome="uploaded"} 1`,
`easyai_gateway_result_poll_responses_total 1`,
`easyai_gateway_result_poll_response_bytes_total 512`,
`easyai_gateway_result_poll_oversized_total 1`,
`easyai_gateway_platform_model_rate_limit_utilization{platform_model_id="platform-model-1",metric="concurrent"} 0.750000`,
`easyai_gateway_async_worker_resizes_total{outcome="success"} 1`,
`easyai_gateway_concurrency_lease_renewals_total{outcome="success"} 1`,
@@ -48,6 +48,8 @@ spec:
- secretRef:
name: easyai-ai-gateway-runtime
env:
- name: GOMEMLIMIT
value: 1536MiB
- name: AI_GATEWAY_PROCESS_ROLE
value: api
- name: AI_GATEWAY_ASYNC_QUEUE_WORKER_ENABLED
@@ -315,6 +317,8 @@ spec:
- secretRef:
name: easyai-ai-gateway-runtime
env:
- name: GOMEMLIMIT
value: 1536MiB
- name: AI_GATEWAY_PROCESS_ROLE
value: api
- name: AI_GATEWAY_ASYNC_QUEUE_WORKER_ENABLED