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:
@@ -14,6 +14,7 @@ import (
|
|||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
apply := flag.Bool("apply", false, "persist compacted results; default is dry-run")
|
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")
|
batchSize := flag.Int("batch-size", 100, "rows per batch, maximum 100")
|
||||||
maxBatches := flag.Int("max-batches", 10, "maximum batches for one invocation")
|
maxBatches := flag.Int("max-batches", 10, "maximum batches for one invocation")
|
||||||
afterID := flag.String("after-id", "", "resume after this task UUID")
|
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)
|
logger.Error("invalid backfill bounds", "batchSize", *batchSize, "maxBatches", *maxBatches)
|
||||||
os.Exit(1)
|
os.Exit(1)
|
||||||
}
|
}
|
||||||
|
if *apply && *requireClean {
|
||||||
|
logger.Error("--apply and --require-clean cannot be combined")
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
db, err := store.Connect(ctx, cfg.DatabaseURL)
|
db, err := store.Connect(ctx, cfg.DatabaseURL)
|
||||||
@@ -42,8 +47,11 @@ func main() {
|
|||||||
cursor := *afterID
|
cursor := *afterID
|
||||||
scanned := 0
|
scanned := 0
|
||||||
matched := 0
|
matched := 0
|
||||||
|
blockingMatched := 0
|
||||||
updated := 0
|
updated := 0
|
||||||
expired := 0
|
expired := 0
|
||||||
|
expiredLocalPlaceholders := 0
|
||||||
|
complete := false
|
||||||
for batch := 0; batch < *maxBatches; batch++ {
|
for batch := 0; batch < *maxBatches; batch++ {
|
||||||
items, err := db.ListTaskBinaryResultBackfillBatch(ctx, cursor, *batchSize)
|
items, err := db.ListTaskBinaryResultBackfillBatch(ctx, cursor, *batchSize)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -51,26 +59,30 @@ func main() {
|
|||||||
os.Exit(1)
|
os.Exit(1)
|
||||||
}
|
}
|
||||||
if len(items) == 0 {
|
if len(items) == 0 {
|
||||||
|
complete = true
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
for _, item := range items {
|
for _, item := range items {
|
||||||
cursor = item.ID
|
cursor = item.ID
|
||||||
scanned++
|
scanned++
|
||||||
if !runner.TaskResultHasInlineBinary(item.Result) {
|
if !runner.TaskResultNeedsURLMigration(item.Result) {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
matched++
|
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 {
|
if !*apply {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
isExpired := item.FinishedAt.Before(time.Now().Add(-time.Duration(localResultTTLHours(cfg)) * time.Hour))
|
persistent, changed, err := service.MigrateTaskResultToURLs(ctx, item.ID, item.Result)
|
||||||
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)
|
|
||||||
}
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
logger.Error("materialize historical binary result failed", "taskId", item.ID, "error", err)
|
logger.Error("materialize historical binary result failed", "taskId", item.ID, "error", err)
|
||||||
os.Exit(1)
|
os.Exit(1)
|
||||||
@@ -92,6 +104,7 @@ func main() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
if len(items) < *batchSize {
|
if len(items) < *batchSize {
|
||||||
|
complete = true
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -99,10 +112,17 @@ func main() {
|
|||||||
"apply", *apply,
|
"apply", *apply,
|
||||||
"scanned", scanned,
|
"scanned", scanned,
|
||||||
"matched", matched,
|
"matched", matched,
|
||||||
|
"blockingMatched", blockingMatched,
|
||||||
"updated", updated,
|
"updated", updated,
|
||||||
"expired", expired,
|
"expired", expired,
|
||||||
|
"expiredLocalPlaceholders", expiredLocalPlaceholders,
|
||||||
|
"complete", complete,
|
||||||
"resumeAfterId", cursor,
|
"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 {
|
func localResultTTLHours(cfg config.Config) int {
|
||||||
|
|||||||
@@ -5656,6 +5656,16 @@
|
|||||||
"description": "OK",
|
"description": "OK",
|
||||||
"schema": {
|
"schema": {
|
||||||
"$ref": "#/definitions/httpapi.EasyAIGeneratedResponse"
|
"$ref": "#/definitions/httpapi.EasyAIGeneratedResponse"
|
||||||
|
},
|
||||||
|
"headers": {
|
||||||
|
"Deprecation": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "output_content 与 result 兼容别名的废弃标记"
|
||||||
|
},
|
||||||
|
"X-Gateway-Response-Format": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "实际结果格式,异步轮询固定为 url"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"404": {
|
"404": {
|
||||||
@@ -11167,6 +11177,7 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"output_content": {
|
"output_content": {
|
||||||
|
"description": "OutputContent is a one-release URL-only compatibility alias for Data.",
|
||||||
"type": "array",
|
"type": "array",
|
||||||
"items": {
|
"items": {
|
||||||
"$ref": "#/definitions/httpapi.EasyAIMediaOutput"
|
"$ref": "#/definitions/httpapi.EasyAIMediaOutput"
|
||||||
@@ -11177,6 +11188,7 @@
|
|||||||
"example": "/api/v1/ai/result/9f4d8f3d-5f5f-4bb7-a4be-344a9f930e25"
|
"example": "/api/v1/ai/result/9f4d8f3d-5f5f-4bb7-a4be-344a9f930e25"
|
||||||
},
|
},
|
||||||
"result": {
|
"result": {
|
||||||
|
"description": "Result is a one-release URL-only compatibility alias and is deprecated.",
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"additionalProperties": true
|
"additionalProperties": true
|
||||||
},
|
},
|
||||||
@@ -11218,7 +11230,7 @@
|
|||||||
"type": "string"
|
"type": "string"
|
||||||
},
|
},
|
||||||
"b64_json": {
|
"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"
|
"type": "string"
|
||||||
},
|
},
|
||||||
"content": {
|
"content": {
|
||||||
|
|||||||
@@ -409,6 +409,8 @@ definitions:
|
|||||||
type: string
|
type: string
|
||||||
type: array
|
type: array
|
||||||
output_content:
|
output_content:
|
||||||
|
description: OutputContent is a one-release URL-only compatibility alias for
|
||||||
|
Data.
|
||||||
items:
|
items:
|
||||||
$ref: '#/definitions/httpapi.EasyAIMediaOutput'
|
$ref: '#/definitions/httpapi.EasyAIMediaOutput'
|
||||||
type: array
|
type: array
|
||||||
@@ -417,6 +419,7 @@ definitions:
|
|||||||
type: string
|
type: string
|
||||||
result:
|
result:
|
||||||
additionalProperties: true
|
additionalProperties: true
|
||||||
|
description: Result is a one-release URL-only compatibility alias and is deprecated.
|
||||||
type: object
|
type: object
|
||||||
status:
|
status:
|
||||||
enum:
|
enum:
|
||||||
@@ -446,7 +449,8 @@ definitions:
|
|||||||
audio_url:
|
audio_url:
|
||||||
type: string
|
type: string
|
||||||
b64_json:
|
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
|
type: string
|
||||||
content:
|
content:
|
||||||
type: string
|
type: string
|
||||||
@@ -8093,6 +8097,13 @@ paths:
|
|||||||
responses:
|
responses:
|
||||||
"200":
|
"200":
|
||||||
description: OK
|
description: OK
|
||||||
|
headers:
|
||||||
|
Deprecation:
|
||||||
|
description: output_content 与 result 兼容别名的废弃标记
|
||||||
|
type: string
|
||||||
|
X-Gateway-Response-Format:
|
||||||
|
description: 实际结果格式,异步轮询固定为 url
|
||||||
|
type: string
|
||||||
schema:
|
schema:
|
||||||
$ref: '#/definitions/httpapi.EasyAIGeneratedResponse'
|
$ref: '#/definitions/httpapi.EasyAIGeneratedResponse'
|
||||||
"404":
|
"404":
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ func (s *Server) hydrateTaskResult(ctx context.Context, task store.GatewayTask)
|
|||||||
if task.Status != "succeeded" || len(task.Result) == 0 {
|
if task.Status != "succeeded" || len(task.Result) == 0 {
|
||||||
return task, nil
|
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 {
|
if err != nil {
|
||||||
return store.GatewayTask{}, err
|
return store.GatewayTask{}, err
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ package httpapi
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"encoding/base64"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
"net/http"
|
"net/http"
|
||||||
@@ -14,6 +15,55 @@ import (
|
|||||||
"github.com/easyai/easyai-ai-gateway/apps/api/internal/store"
|
"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) {
|
func TestProtocolAPIKeyStoreFailureIs503InsteadOf401(t *testing.T) {
|
||||||
authenticator := auth.New("test-secret", "", "")
|
authenticator := auth.New("test-secret", "", "")
|
||||||
authenticator.LocalAPIKeyVerifier = func(context.Context, string) (*auth.User, error) {
|
authenticator.LocalAPIKeyVerifier = func(context.Context, string) (*auth.User, error) {
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
package httpapi
|
package httpapi
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"encoding/json"
|
|
||||||
"net/http"
|
"net/http"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
@@ -109,32 +108,31 @@ func easyAIFileUploadResponse(upload map[string]any) map[string]any {
|
|||||||
|
|
||||||
func easyAITaskResultResponse(task store.GatewayTask) map[string]any {
|
func easyAITaskResultResponse(task store.GatewayTask) map[string]any {
|
||||||
sourceResult := cloneEasyAIMap(task.Result)
|
sourceResult := cloneEasyAIMap(task.Result)
|
||||||
cleanResult := cloneEasyAIMap(sourceResult)
|
|
||||||
delete(cleanResult, "raw")
|
|
||||||
delete(cleanResult, "raw_data")
|
|
||||||
normalizeEasyAIInlineMediaFields(cleanResult)
|
|
||||||
|
|
||||||
data := easyAITaskResultData(task, sourceResult)
|
data := easyAITaskResultData(task, sourceResult)
|
||||||
status := easyAITaskResultStatus(task.Status)
|
status := easyAITaskResultStatus(task.Status)
|
||||||
if status == "failed" {
|
if status == "failed" {
|
||||||
data = []any{}
|
data = []any{}
|
||||||
}
|
}
|
||||||
cleanResult["data"] = data
|
output := easyAIOutputURLs(data)
|
||||||
cleanResult["output_content"] = data
|
compatResult := map[string]any{
|
||||||
|
"data": data,
|
||||||
response := cloneEasyAIMap(cleanResult)
|
"output": output,
|
||||||
response["status"] = status
|
"output_content": data,
|
||||||
response["task_id"] = task.ID
|
}
|
||||||
response["taskId"] = task.ID
|
response := map[string]any{
|
||||||
response["query_url"] = "/api/v1/ai/result/" + task.ID
|
"status": status,
|
||||||
response["created"] = task.CreatedAt.UnixMilli()
|
"task_id": task.ID,
|
||||||
response["data"] = data
|
"taskId": task.ID,
|
||||||
response["output_content"] = data
|
"query_url": "/api/v1/ai/result/" + task.ID,
|
||||||
response["output"] = easyAIOutputURLs(data)
|
"created": task.CreatedAt.UnixMilli(),
|
||||||
response["result"] = cleanResult
|
"data": data,
|
||||||
|
"output": output,
|
||||||
|
"output_content": data,
|
||||||
|
"result": compatResult,
|
||||||
|
}
|
||||||
|
|
||||||
upstreamTaskID := firstNonEmpty(
|
upstreamTaskID := firstNonEmpty(
|
||||||
easyAIString(cleanResult["upstream_task_id"]),
|
easyAIString(sourceResult["upstream_task_id"]),
|
||||||
task.RemoteTaskID,
|
task.RemoteTaskID,
|
||||||
)
|
)
|
||||||
if upstreamTaskID != "" {
|
if upstreamTaskID != "" {
|
||||||
@@ -144,12 +142,14 @@ func easyAITaskResultResponse(task store.GatewayTask) map[string]any {
|
|||||||
response["usage"] = task.Usage
|
response["usage"] = task.Usage
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if status != "success" {
|
||||||
cancelState := runner.DescribeTaskCancellation(task)
|
cancelState := runner.DescribeTaskCancellation(task)
|
||||||
response["cancellable"] = cancelState.Cancellable
|
response["cancellable"] = cancelState.Cancellable
|
||||||
response["submitted"] = cancelState.Submitted
|
response["submitted"] = cancelState.Submitted
|
||||||
|
}
|
||||||
|
|
||||||
message := firstNonEmpty(
|
message := firstNonEmpty(
|
||||||
easyAIString(cleanResult["message"]),
|
easyAIString(sourceResult["message"]),
|
||||||
task.ErrorMessage,
|
task.ErrorMessage,
|
||||||
task.Error,
|
task.Error,
|
||||||
task.Message,
|
task.Message,
|
||||||
@@ -164,10 +164,10 @@ func easyAITaskResultResponse(task store.GatewayTask) map[string]any {
|
|||||||
message = "任务执行失败"
|
message = "任务执行失败"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if message != "" {
|
if message != "" && status != "success" {
|
||||||
response["message"] = message
|
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)
|
standard := publicTaskError(task)
|
||||||
if code != "" && task.ErrorCode == "" {
|
if code != "" && task.ErrorCode == "" {
|
||||||
standard = publicerror.WithIDs(publicerror.FromFields(code, message, 0, false), task.RequestID, task.ID)
|
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 {
|
if len(output) == 0 {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
delete(output, "raw_data")
|
|
||||||
normalizeEasyAIInlineMediaFields(output)
|
normalizeEasyAIInlineMediaFields(output)
|
||||||
|
|
||||||
mediaURL := easyAIOutputURL(output)
|
mediaURL := easyAIOutputURL(output)
|
||||||
if mediaURL != "" {
|
if mediaURL == "" {
|
||||||
output["url"] = mediaURL
|
continue
|
||||||
}
|
}
|
||||||
if strings.TrimSpace(easyAIString(output["type"])) == "" {
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(easyAIString(lightweight["type"])) == "" {
|
||||||
if outputType := easyAIOutputType(task, output, mediaURL); outputType != "" {
|
if outputType := easyAIOutputType(task, output, mediaURL); outputType != "" {
|
||||||
output["type"] = outputType
|
lightweight["type"] = outputType
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
normalized = append(normalized, output)
|
normalized = append(normalized, lightweight)
|
||||||
}
|
}
|
||||||
return normalized
|
return normalized
|
||||||
}
|
}
|
||||||
@@ -492,13 +497,26 @@ func cloneEasyAIMap(source map[string]any) map[string]any {
|
|||||||
if len(source) == 0 {
|
if len(source) == 0 {
|
||||||
return map[string]any{}
|
return map[string]any{}
|
||||||
}
|
}
|
||||||
raw, err := json.Marshal(source)
|
result := make(map[string]any, len(source))
|
||||||
if err != nil {
|
for key, value := range source {
|
||||||
return map[string]any{}
|
result[key] = cloneEasyAIValue(value)
|
||||||
}
|
|
||||||
var result map[string]any
|
|
||||||
if err := json.Unmarshal(raw, &result); err != nil {
|
|
||||||
return map[string]any{}
|
|
||||||
}
|
}
|
||||||
return result
|
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"
|
"encoding/json"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
|
"runtime"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@@ -11,6 +14,98 @@ import (
|
|||||||
"github.com/easyai/easyai-ai-gateway/apps/api/internal/store"
|
"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) {
|
func TestEasyAITaskAcceptedResponseKeepsGatewayFieldsAndAddsLegacyFields(t *testing.T) {
|
||||||
task := store.GatewayTask{
|
task := store.GatewayTask{
|
||||||
ID: "task-accepted-1",
|
ID: "task-accepted-1",
|
||||||
@@ -119,8 +214,8 @@ func TestEasyAITaskResultResponseNormalizesMediaOutputs(t *testing.T) {
|
|||||||
t.Fatalf("output_content is not synchronized: %+v", got)
|
t.Fatalf("output_content is not synchronized: %+v", got)
|
||||||
}
|
}
|
||||||
if item.wantCount == 0 {
|
if item.wantCount == 0 {
|
||||||
if got["voice_id"] != "voice-test-1" {
|
if got["voice_id"] != nil || got["cloned_voice"] != nil {
|
||||||
t.Fatalf("voice clone fields were lost: %+v", got)
|
t.Fatalf("provider-specific fields leaked: %+v", got)
|
||||||
}
|
}
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -139,7 +234,7 @@ func TestEasyAITaskResultResponseNormalizesMediaOutputs(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestEasyAITaskResultResponsePreservesBase64ImageOutput(t *testing.T) {
|
func TestEasyAITaskResultResponseDropsBase64ImageOutput(t *testing.T) {
|
||||||
base64Payload := "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAusB9Y9ZlB8AAAAASUVORK5CYII="
|
base64Payload := "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAusB9Y9ZlB8AAAAASUVORK5CYII="
|
||||||
task := store.GatewayTask{
|
task := store.GatewayTask{
|
||||||
ID: "image-base64-1", Kind: "images.generations", Status: "succeeded",
|
ID: "image-base64-1", Kind: "images.generations", Status: "succeeded",
|
||||||
@@ -151,20 +246,8 @@ func TestEasyAITaskResultResponsePreservesBase64ImageOutput(t *testing.T) {
|
|||||||
|
|
||||||
got := easyAITaskResultResponse(task)
|
got := easyAITaskResultResponse(task)
|
||||||
data, _ := got["data"].([]any)
|
data, _ := got["data"].([]any)
|
||||||
if len(data) != 1 {
|
if len(data) != 0 {
|
||||||
t.Fatalf("unexpected base64 output count: %+v", got)
|
t.Fatalf("Base64 output leaked into URL-only response: %+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)
|
|
||||||
}
|
}
|
||||||
urls, _ := got["output"].([]string)
|
urls, _ := got["output"].([]string)
|
||||||
if len(urls) != 0 {
|
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="
|
base64Payload := "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAusB9Y9ZlB8AAAAASUVORK5CYII="
|
||||||
task := store.GatewayTask{
|
task := store.GatewayTask{
|
||||||
ID: "image-data-url-1", Kind: "images.generations", Status: "succeeded",
|
ID: "image-data-url-1", Kind: "images.generations", Status: "succeeded",
|
||||||
@@ -183,12 +266,8 @@ func TestEasyAITaskResultResponseMovesImageDataURLToB64JSON(t *testing.T) {
|
|||||||
|
|
||||||
got := easyAITaskResultResponse(task)
|
got := easyAITaskResultResponse(task)
|
||||||
data, _ := got["data"].([]any)
|
data, _ := got["data"].([]any)
|
||||||
output, _ := data[0].(map[string]any)
|
if len(data) != 0 {
|
||||||
if output["b64_json"] != base64Payload || output["mime_type"] != "image/png" || output["type"] != "image" {
|
t.Fatalf("data URL leaked into URL-only response: %+v", got)
|
||||||
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)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -220,7 +299,7 @@ func TestEasyAITaskResultResponseForwardsSafeUpstreamParameterMessage(t *testing
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestEasyAITaskResultResponsePreservesAudioDataURLAsContent(t *testing.T) {
|
func TestEasyAITaskResultResponseDropsAudioDataURL(t *testing.T) {
|
||||||
base64Payload := "SUQzBAAAAAAAI1RTU0UAAAAPAAADTGF2ZjYwLjMuMTAwAAAAAAAAAAAAAAD/"
|
base64Payload := "SUQzBAAAAAAAI1RTU0UAAAAPAAADTGF2ZjYwLjMuMTAwAAAAAAAAAAAAAAD/"
|
||||||
task := store.GatewayTask{
|
task := store.GatewayTask{
|
||||||
ID: "audio-data-url-1", Kind: "speech.generations", Status: "succeeded",
|
ID: "audio-data-url-1", Kind: "speech.generations", Status: "succeeded",
|
||||||
@@ -231,14 +310,8 @@ func TestEasyAITaskResultResponsePreservesAudioDataURLAsContent(t *testing.T) {
|
|||||||
|
|
||||||
got := easyAITaskResultResponse(task)
|
got := easyAITaskResultResponse(task)
|
||||||
data, _ := got["data"].([]any)
|
data, _ := got["data"].([]any)
|
||||||
output, _ := data[0].(map[string]any)
|
if len(data) != 0 {
|
||||||
if output["content"] != "data:audio/mpeg;base64,"+base64Payload ||
|
t.Fatalf("audio data URL leaked into URL-only response: %+v", got)
|
||||||
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)
|
|
||||||
}
|
}
|
||||||
urls, _ := got["output"].([]string)
|
urls, _ := got["output"].([]string)
|
||||||
if len(urls) != 0 {
|
if len(urls) != 0 {
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ package httpapi
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"encoding/base64"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
@@ -1166,6 +1167,10 @@ func (s *Server) createTask(kind string, compatible bool) http.Handler {
|
|||||||
return
|
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)
|
requestedModel := requestModelName(body)
|
||||||
model := canonicalTaskModelName(kind, requestedModel)
|
model := canonicalTaskModelName(kind, requestedModel)
|
||||||
if model == "" {
|
if model == "" {
|
||||||
@@ -1550,6 +1555,12 @@ func writeProtocolCompatibleTaskResponse(runCtx context.Context, w http.Response
|
|||||||
return
|
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)
|
result, runErr := executor.Execute(runCtx, task, user)
|
||||||
if runErr != nil {
|
if runErr != nil {
|
||||||
if !requestStillConnected(r) {
|
if !requestStillConnected(r) {
|
||||||
@@ -1566,13 +1577,107 @@ func writeProtocolCompatibleTaskResponse(runCtx context.Context, w http.Response
|
|||||||
if !requestStillConnected(r) {
|
if !requestStillConnected(r) {
|
||||||
return
|
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)
|
writeWireResponse(w, result.Wire)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
writeJSON(w, http.StatusOK, easyAISynchronousTaskResponse(result.Task, result.Output))
|
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 {
|
func gatewayAPIV1Request(r *http.Request) bool {
|
||||||
return r != nil && strings.HasPrefix(r.URL.Path, "/api/v1/")
|
return r != nil && strings.HasPrefix(r.URL.Path, "/api/v1/")
|
||||||
}
|
}
|
||||||
@@ -1583,6 +1688,27 @@ func streamIncludeUsage(body map[string]any) bool {
|
|||||||
return includeUsage
|
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 {
|
func asyncRequest(r *http.Request) bool {
|
||||||
value := strings.TrimSpace(strings.ToLower(r.Header.Get("x-async")))
|
value := strings.TrimSpace(strings.ToLower(r.Header.Get("x-async")))
|
||||||
return value == "1" || value == "true" || value == "yes" || value == "on"
|
return value == "1" || value == "true" || value == "yes" || value == "on"
|
||||||
|
|||||||
@@ -305,9 +305,11 @@ type EasyAIGeneratedResponse struct {
|
|||||||
Code string `json:"code,omitempty"`
|
Code string `json:"code,omitempty"`
|
||||||
Data []EasyAIMediaOutput `json:"data"`
|
Data []EasyAIMediaOutput `json:"data"`
|
||||||
Output []string `json:"output"`
|
Output []string `json:"output"`
|
||||||
|
// OutputContent is a one-release URL-only compatibility alias for Data.
|
||||||
OutputContent []EasyAIMediaOutput `json:"output_content"`
|
OutputContent []EasyAIMediaOutput `json:"output_content"`
|
||||||
Cancellable bool `json:"cancellable"`
|
Cancellable bool `json:"cancellable"`
|
||||||
Submitted bool `json:"submitted"`
|
Submitted bool `json:"submitted"`
|
||||||
|
// Result is a one-release URL-only compatibility alias and is deprecated.
|
||||||
Result map[string]interface{} `json:"result,omitempty"`
|
Result map[string]interface{} `json:"result,omitempty"`
|
||||||
Usage map[string]interface{} `json:"usage,omitempty"`
|
Usage map[string]interface{} `json:"usage,omitempty"`
|
||||||
}
|
}
|
||||||
@@ -315,7 +317,7 @@ type EasyAIGeneratedResponse struct {
|
|||||||
type EasyAIMediaOutput struct {
|
type EasyAIMediaOutput struct {
|
||||||
Type string `json:"type,omitempty" example:"video" enums:"image,video,audio,file,text"`
|
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"`
|
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"`
|
B64JSON string `json:"b64_json,omitempty"`
|
||||||
ImageURL string `json:"image_url,omitempty"`
|
ImageURL string `json:"image_url,omitempty"`
|
||||||
VideoURL string `json:"video_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...)
|
task.Attempts = append([]store.TaskAttempt(nil), task.Attempts...)
|
||||||
for index := range task.Attempts {
|
for index := range task.Attempts {
|
||||||
attempt := &task.Attempts[index]
|
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 == "" {
|
if attempt.ErrorCode == "" && attempt.ErrorMessage == "" {
|
||||||
continue
|
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"}`
|
rawMessage := `404 page not found: {"privateProject":"secret"}`
|
||||||
task := store.GatewayTask{
|
task := store.GatewayTask{
|
||||||
ID: "task-1",
|
ID: "task-1",
|
||||||
@@ -79,6 +79,8 @@ func TestPublicGatewayTaskSanitizesCopyAndKeepsRawAuditFields(t *testing.T) {
|
|||||||
StatusCode: http.StatusNotFound,
|
StatusCode: http.StatusNotFound,
|
||||||
ErrorCode: "http_404",
|
ErrorCode: "http_404",
|
||||||
ErrorMessage: rawMessage,
|
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 {
|
if strings.Contains(public.ErrorMessage, "secret") || public.Attempts[0].ErrorMessage == rawMessage {
|
||||||
t.Fatalf("public task leaked upstream details: %+v", public)
|
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 {
|
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)
|
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) {
|
func TestPublicGatewayTaskForwardsSafeUpstreamParameterMessage(t *testing.T) {
|
||||||
|
|||||||
@@ -9,12 +9,43 @@ import (
|
|||||||
"github.com/easyai/easyai-ai-gateway/apps/api/internal/publicerror"
|
"github.com/easyai/easyai-ai-gateway/apps/api/internal/publicerror"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const maxAsyncMediaResultResponseBytes = 64 << 10
|
||||||
|
|
||||||
func writeJSON(w http.ResponseWriter, status int, value any) {
|
func writeJSON(w http.ResponseWriter, status int, value any) {
|
||||||
w.Header().Set("Content-Type", "application/json; charset=utf-8")
|
w.Header().Set("Content-Type", "application/json; charset=utf-8")
|
||||||
w.WriteHeader(status)
|
w.WriteHeader(status)
|
||||||
_ = json.NewEncoder(w).Encode(value)
|
_ = 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) {
|
func writeError(w http.ResponseWriter, status int, message string, codes ...string) {
|
||||||
writeErrorWithDetails(w, status, message, nil, codes...)
|
writeErrorWithDetails(w, status, message, nil, codes...)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -185,6 +185,8 @@ func (s *Server) createLegacyVolcesVideoGeneration(w http.ResponseWriter, r *htt
|
|||||||
// @Security BearerAuth
|
// @Security BearerAuth
|
||||||
// @Param taskID path string true "任务 ID"
|
// @Param taskID path string true "任务 ID"
|
||||||
// @Success 200 {object} EasyAIGeneratedResponse
|
// @Success 200 {object} EasyAIGeneratedResponse
|
||||||
|
// @Header 200 {string} X-Gateway-Response-Format "实际结果格式,异步轮询固定为 url"
|
||||||
|
// @Header 200 {string} Deprecation "output_content 与 result 兼容别名的废弃标记"
|
||||||
// @Failure 404 {object} EasyAIGeneratedResponse
|
// @Failure 404 {object} EasyAIGeneratedResponse
|
||||||
// @Failure 410 {object} EasyAIGeneratedResponse
|
// @Failure 410 {object} EasyAIGeneratedResponse
|
||||||
// @Failure 503 {object} ErrorEnvelope
|
// @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))
|
writeEasyAIAsyncError(w, statusFromRunError(err), err.Error(), nil, clients.ErrorCode(err))
|
||||||
return
|
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) {
|
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
|
break
|
||||||
}
|
}
|
||||||
return newError("upstream_unavailable", "The upstream service is temporarily unavailable.", "upstream", http.StatusServiceUnavailable, true, "retry"), true
|
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":
|
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
|
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":
|
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
|
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":
|
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
|
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":
|
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
|
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":
|
case "result_unavailable", "request_asset_expired":
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import (
|
|||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
|
"net/http"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"sort"
|
"sort"
|
||||||
@@ -21,6 +22,9 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
|
// MaxSynchronousInlineResponseBytes bounds the raw media payload restored for
|
||||||
|
// an explicit synchronous OpenAI-compatible b64_json response.
|
||||||
|
MaxSynchronousInlineResponseBytes = int64(20 << 20)
|
||||||
localBinaryResultDirName = "results"
|
localBinaryResultDirName = "results"
|
||||||
localBinaryPlaceholderPrefix = "[GatewayBinary:v1;"
|
localBinaryPlaceholderPrefix = "[GatewayBinary:v1;"
|
||||||
localBinaryGenericBase64MinLength = 4096
|
localBinaryGenericBase64MinLength = 4096
|
||||||
@@ -96,6 +100,225 @@ func (s *Service) MaterializeTaskResultForStorage(ctx context.Context, taskID st
|
|||||||
return next, hadInline && !TaskResultHasInlineBinary(next), nil
|
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
|
// CompactExpiredTaskResultForStorage keeps the historical maintenance API but
|
||||||
// now uses the same object-storage path as live results. New GatewayBinary
|
// now uses the same object-storage path as live results. New GatewayBinary
|
||||||
// placeholders are never created; their parser remains read-only compatibility.
|
// placeholders are never created; their parser remains read-only compatibility.
|
||||||
@@ -325,8 +548,9 @@ func verifyLocalBinaryFile(path string, expectedHash string, expectedSize int64)
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// HydrateTaskResult restores placeholders from verified local files. It is only
|
// HydrateTaskResult restores verified stored payloads only for bounded,
|
||||||
// used by result/detail/replay endpoints, never by task lists or callbacks.
|
// 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) {
|
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)
|
next, changed, err := s.hydrateLocalBinaryValue(ctx, taskID, result, 0)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -342,6 +566,244 @@ func (s *Service) HydrateTaskResult(ctx context.Context, taskID string, result m
|
|||||||
return mapped, nil
|
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) {
|
func (s *Service) hydrateLocalBinaryValue(ctx context.Context, taskID string, value any, depth int) (any, bool, error) {
|
||||||
if err := ctx.Err(); err != nil {
|
if err := ctx.Err(); err != nil {
|
||||||
return nil, false, err
|
return nil, false, err
|
||||||
@@ -374,6 +836,31 @@ func (s *Service) hydrateLocalBinaryValue(ctx context.Context, taskID string, va
|
|||||||
}
|
}
|
||||||
typed = next
|
typed = next
|
||||||
refreshedAccessURL = true
|
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 {
|
if ref, ok := generatedResultAssetReference(typed); ok {
|
||||||
payload, contentType, err := s.readGeneratedResultAsset(ctx, ref)
|
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))
|
next := make(map[string]any, len(typed))
|
||||||
changed := refreshedAccessURL
|
changed := refreshedAccessURL
|
||||||
for key, childValue := range typed {
|
for key, childValue := range typed {
|
||||||
|
if resultInternalField(key) {
|
||||||
|
changed = true
|
||||||
|
continue
|
||||||
|
}
|
||||||
child, childChanged, err := s.hydrateLocalBinaryValue(ctx, taskID, childValue, depth+1)
|
child, childChanged, err := s.hydrateLocalBinaryValue(ctx, taskID, childValue, depth+1)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, false, err
|
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{}, nil, false
|
||||||
}
|
}
|
||||||
return store.RequestAsset{
|
return store.RequestAsset{
|
||||||
|
SHA256: strings.ToLower(strings.TrimSpace(stringFromAny(upload["sha256"]))),
|
||||||
|
ContentType: stringFromAny(upload["contentType"]),
|
||||||
|
ByteSize: int64(floatFromAny(upload["size"])),
|
||||||
URL: stringFromAny(upload["url"]),
|
URL: stringFromAny(upload["url"]),
|
||||||
StorageProvider: stringFromAny(channel["provider"]),
|
StorageProvider: stringFromAny(channel["provider"]),
|
||||||
StorageChannelID: stringFromAny(channel["id"]),
|
StorageChannelID: stringFromAny(channel["id"]),
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import (
|
|||||||
"encoding/hex"
|
"encoding/hex"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
|
"fmt"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
"os"
|
"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) {
|
func TestHydrateLocalBinaryResultReturnsExpiredAndCorruptedErrors(t *testing.T) {
|
||||||
service := newLocalBinaryTestService(t)
|
service := newLocalBinaryTestService(t)
|
||||||
service.cfg.LocalResultTTLHours = 1
|
service.cfg.LocalResultTTLHours = 1
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import (
|
|||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
|
"net/http"
|
||||||
"os"
|
"os"
|
||||||
"regexp"
|
"regexp"
|
||||||
"strconv"
|
"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) {
|
func (s *Service) Execute(ctx context.Context, task store.GatewayTask, user *auth.User) (Result, error) {
|
||||||
return s.execute(ctx, task, user, nil)
|
return s.execute(ctx, task, user, nil)
|
||||||
}
|
}
|
||||||
@@ -982,7 +990,7 @@ candidatesLoop:
|
|||||||
}
|
}
|
||||||
walletReservationFinalized = true
|
walletReservationFinalized = true
|
||||||
s.logger.Warn("task succeeded but billing requires manual review", "taskID", task.ID, "error_category", "billing_calculation_failed")
|
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 {
|
if hydrateErr != nil {
|
||||||
return Result{Task: review}, hydrateErr
|
return Result{Task: review}, hydrateErr
|
||||||
}
|
}
|
||||||
@@ -1075,7 +1083,7 @@ candidatesLoop:
|
|||||||
// time after the task is already durably complete.
|
// time after the task is already durably complete.
|
||||||
return Result{Task: finished, Output: response.Result}, nil
|
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 {
|
if hydrateErr != nil {
|
||||||
return Result{Task: finished}, hydrateErr
|
return Result{Task: finished}, hydrateErr
|
||||||
}
|
}
|
||||||
@@ -1272,6 +1280,33 @@ candidatesLoop:
|
|||||||
return Result{Task: failed, Output: failed.Result}, lastErr
|
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 {
|
func pricingCandidateKey(candidate store.RuntimeModelCandidate) string {
|
||||||
return firstNonEmptyString(candidate.PlatformModelID, candidate.PlatformID+":"+candidate.ModelName)
|
return firstNonEmptyString(candidate.PlatformModelID, candidate.PlatformID+":"+candidate.ModelName)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -50,6 +50,13 @@ type generatedAssetDecision struct {
|
|||||||
StripKeys []string
|
StripKeys []string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type generatedAssetUploadResult struct {
|
||||||
|
upload map[string]any
|
||||||
|
contentType string
|
||||||
|
kind string
|
||||||
|
strategy string
|
||||||
|
}
|
||||||
|
|
||||||
type generatedInlineAsset struct {
|
type generatedInlineAsset struct {
|
||||||
Bytes []byte
|
Bytes []byte
|
||||||
ContentType string
|
ContentType string
|
||||||
@@ -150,6 +157,9 @@ func (s *Service) uploadGeneratedAssets(ctx context.Context, taskID string, task
|
|||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
decisions[index] = decision
|
decisions[index] = decision
|
||||||
|
if _, mediaURL := mediaURLSourceFromItem(item); mediaURL != "" && decision.URL == nil {
|
||||||
|
s.observeResultStorage("upstream_url")
|
||||||
|
}
|
||||||
if decision.Inline != nil || decision.URL != nil {
|
if decision.Inline != nil || decision.URL != nil {
|
||||||
needsUpload = true
|
needsUpload = true
|
||||||
}
|
}
|
||||||
@@ -175,6 +185,7 @@ func (s *Service) uploadGeneratedAssets(ctx context.Context, taskID string, task
|
|||||||
next[key] = value
|
next[key] = value
|
||||||
}
|
}
|
||||||
nextData := make([]any, 0, len(data))
|
nextData := make([]any, 0, len(data))
|
||||||
|
uploadCache := make(map[string]generatedAssetUploadResult)
|
||||||
for index, rawItem := range data {
|
for index, rawItem := range data {
|
||||||
item, _ := rawItem.(map[string]any)
|
item, _ := rawItem.(map[string]any)
|
||||||
if item == nil {
|
if item == nil {
|
||||||
@@ -197,7 +208,15 @@ func (s *Service) uploadGeneratedAssets(ctx context.Context, taskID string, task
|
|||||||
var contentType string
|
var contentType string
|
||||||
var err error
|
var err error
|
||||||
if decision.Inline != nil {
|
if decision.Inline != nil {
|
||||||
|
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)
|
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
|
sourceKey = decision.Inline.SourceKey
|
||||||
} else {
|
} else {
|
||||||
upload, contentType, kind, strategy, err = s.uploadGeneratedURLAsset(ctx, taskID, decision.URL, index, channels, acceptanceEmulatorBaseURL)
|
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"]) == "" {
|
if contentType != "" && stringFromAny(merged["mime_type"]) == "" {
|
||||||
merged["mime_type"] = contentType
|
merged["mime_type"] = contentType
|
||||||
}
|
}
|
||||||
if decision.Inline != nil && strings.TrimSpace(sourceKey) != "" {
|
|
||||||
merged[sourceKey] = generatedRawMediaReference(decision.Inline, upload, contentType, kind, strategy)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
nextData = append(nextData, merged)
|
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))
|
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 {
|
func generatedAssetUploadPolicyForAcceptanceRun(policy generatedAssetUploadPolicy, acceptanceRunID string) generatedAssetUploadPolicy {
|
||||||
if strings.TrimSpace(acceptanceRunID) != "" {
|
if strings.TrimSpace(acceptanceRunID) != "" {
|
||||||
policy.UploadURLMedia = true
|
policy.UploadURLMedia = true
|
||||||
@@ -274,7 +299,7 @@ func (s *Service) finalizeGeneratedAssets(
|
|||||||
) (map[string]any, error) {
|
) (map[string]any, error) {
|
||||||
redactGeneratedResultRawData(result)
|
redactGeneratedResultRawData(result)
|
||||||
if !TaskResultHasInlineBinary(result) {
|
if !TaskResultHasInlineBinary(result) {
|
||||||
return result, nil
|
return canonicalStoredResultURLs(result), nil
|
||||||
}
|
}
|
||||||
next := result
|
next := result
|
||||||
if policy.UploadInlineMedia {
|
if policy.UploadInlineMedia {
|
||||||
@@ -304,7 +329,7 @@ func (s *Service) finalizeGeneratedAssets(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
if !TaskResultHasInlineBinary(next) {
|
if !TaskResultHasInlineBinary(next) {
|
||||||
return next, nil
|
return canonicalStoredResultURLs(next), nil
|
||||||
}
|
}
|
||||||
diagnostics := taskResultInlineBinaryDiagnostics(next)
|
diagnostics := taskResultInlineBinaryDiagnostics(next)
|
||||||
if s.logger != nil {
|
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 {
|
func generatedRawValueHasInlineMedia(value any, key string, siblings map[string]any) bool {
|
||||||
switch typed := value.(type) {
|
switch typed := value.(type) {
|
||||||
case map[string]any:
|
case map[string]any:
|
||||||
@@ -448,41 +485,8 @@ 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 {
|
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"])
|
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{
|
out := map[string]any{
|
||||||
"assetRef": ref,
|
|
||||||
"upload": upload,
|
"upload": upload,
|
||||||
"assetStorage": map[string]any{
|
"assetStorage": map[string]any{
|
||||||
"scene": store.FileStorageSceneImageResult,
|
"scene": store.FileStorageSceneImageResult,
|
||||||
@@ -497,6 +501,9 @@ func generatedRawMediaReference(asset *generatedInlineAsset, upload map[string]a
|
|||||||
if kind != "" {
|
if kind != "" {
|
||||||
out["type"] = kind
|
out["type"] = kind
|
||||||
}
|
}
|
||||||
|
if contentType != "" {
|
||||||
|
out["mime_type"] = contentType
|
||||||
|
}
|
||||||
return out
|
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}
|
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)
|
upload, err := s.uploadFileWithFailover(ctx, payload, channels)
|
||||||
|
if err == nil {
|
||||||
|
s.observeResultStorage("uploaded")
|
||||||
|
}
|
||||||
return upload, contentType, kind, "upload_inline_media", err
|
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}
|
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)
|
upload, err := s.uploadFileWithFailover(ctx, uploadPayload, channels)
|
||||||
|
if err == nil {
|
||||||
|
s.observeResultStorage("uploaded")
|
||||||
|
}
|
||||||
return upload, contentType, kind, "upload_url_media", err
|
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 {
|
func storageFailureAllowsFailover(channel store.FileStorageChannel, err error) bool {
|
||||||
code := strings.ToLower(strings.TrimSpace(clients.ErrorCode(err)))
|
code := strings.ToLower(strings.TrimSpace(clients.ErrorCode(err)))
|
||||||
switch code {
|
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
|
return false
|
||||||
}
|
}
|
||||||
var clientErr *clients.ClientError
|
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) {
|
func generatedAssetDecisionForItem(taskKind string, item map[string]any, policy generatedAssetUploadPolicy) (generatedAssetDecision, error) {
|
||||||
decision := generatedAssetDecision{}
|
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)
|
urlKey, mediaURL := mediaURLSourceFromItem(item)
|
||||||
if mediaURL != "" {
|
if mediaURL != "" {
|
||||||
if !policy.UploadURLMedia {
|
if !policy.UploadURLMedia {
|
||||||
@@ -1225,7 +1244,7 @@ func inlineMediaPayloadFromString(value string, strictBase64 bool) ([]byte, stri
|
|||||||
}
|
}
|
||||||
payload, err := decodeBase64Payload(encoded)
|
payload, err := decodeBase64Payload(encoded)
|
||||||
if err != nil {
|
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
|
return payload, contentType, true, nil
|
||||||
}
|
}
|
||||||
@@ -1235,7 +1254,7 @@ func inlineMediaPayloadFromString(value string, strictBase64 bool) ([]byte, stri
|
|||||||
payload, err := decodeBase64Payload(raw)
|
payload, err := decodeBase64Payload(raw)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if strictBase64 {
|
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
|
return nil, "", false, nil
|
||||||
}
|
}
|
||||||
@@ -1245,7 +1264,7 @@ func inlineMediaPayloadFromString(value string, strictBase64 bool) ([]byte, stri
|
|||||||
func parseBase64DataURL(value string) (string, string, bool, error) {
|
func parseBase64DataURL(value string) (string, string, bool, error) {
|
||||||
prefix, payload, ok := strings.Cut(value, ",")
|
prefix, payload, ok := strings.Cut(value, ",")
|
||||||
if !ok {
|
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(prefix, "data:")
|
||||||
meta = strings.TrimPrefix(meta, "DATA:")
|
meta = strings.TrimPrefix(meta, "DATA:")
|
||||||
@@ -1259,7 +1278,7 @@ func parseBase64DataURL(value string) (string, string, bool, error) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
if !isBase64 {
|
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
|
return contentType, payload, true, nil
|
||||||
}
|
}
|
||||||
@@ -1456,10 +1475,11 @@ func mediaURLString(value string) bool {
|
|||||||
if strings.HasPrefix(lower, "data:") {
|
if strings.HasPrefix(lower, "data:") {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
return strings.HasPrefix(lower, "http://") ||
|
if strings.HasPrefix(lower, "/") {
|
||||||
strings.HasPrefix(lower, "https://") ||
|
return true
|
||||||
strings.HasPrefix(lower, "/") ||
|
}
|
||||||
strings.Contains(lower, "://")
|
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 {
|
func mediaContentTypeFromItem(item map[string]any) string {
|
||||||
|
|||||||
@@ -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) {
|
func TestMediaResultMaterializationConcurrencyIsBounded(t *testing.T) {
|
||||||
service := New(config.Config{MediaMaterializationConcurrency: 1}, nil, nil)
|
service := New(config.Config{MediaMaterializationConcurrency: 1}, nil, nil)
|
||||||
result := map[string]any{
|
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) {
|
func TestGeneratedAssetUploadPolicyFromName(t *testing.T) {
|
||||||
tests := []struct {
|
tests := []struct {
|
||||||
name string
|
name string
|
||||||
@@ -329,13 +375,15 @@ func TestFinalizeGeneratedAssetsUploadsNestedInlineBinaryUnderDefaultPolicy(t *t
|
|||||||
t.Fatal("finalized result still contains inline binary")
|
t.Fatal("finalized result still contains inline binary")
|
||||||
}
|
}
|
||||||
nested := finalized["provider_result"].(map[string]any)
|
nested := finalized["provider_result"].(map[string]any)
|
||||||
reference, ok := nested["binary_data_base64"].(map[string]any)
|
if _, exists := nested["binary_data_base64"]; exists {
|
||||||
if !ok {
|
t.Fatalf("nested Base64 field was retained: %+v", nested)
|
||||||
t.Fatalf("nested binary should be replaced by an asset reference: %+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)
|
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) {
|
func TestFinalizeGeneratedAssetsMaterializesDetectedMediaAndKeepsOpaqueMetadata(t *testing.T) {
|
||||||
@@ -368,7 +416,7 @@ func TestFinalizeGeneratedAssetsMaterializesDetectedMediaAndKeepsOpaqueMetadata(
|
|||||||
t.Fatalf("finalized result still contains generated media: %#v", finalized)
|
t.Fatalf("finalized result still contains generated media: %#v", finalized)
|
||||||
}
|
}
|
||||||
reference, ok := finalized["provider_payload"].(map[string]any)
|
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"])
|
t.Fatalf("detected media was not objectified: %#v", finalized["provider_payload"])
|
||||||
}
|
}
|
||||||
if finalized["thought_signature"] != opaque {
|
if finalized["thought_signature"] != opaque {
|
||||||
@@ -407,9 +455,11 @@ func TestFinalizeGeneratedAssetsMaterializesGeminiImageData(t *testing.T) {
|
|||||||
}
|
}
|
||||||
data := finalized["data"].([]any)
|
data := finalized["data"].([]any)
|
||||||
item := data[0].(map[string]any)
|
item := data[0].(map[string]any)
|
||||||
reference, ok := item["b64_json"].(map[string]any)
|
if _, exists := item["b64_json"]; exists {
|
||||||
if !ok || reference["assetRef"] == nil || reference["upload"] == nil {
|
t.Fatalf("Gemini b64_json was retained: %#v", item)
|
||||||
t.Fatalf("Gemini b64_json was not replaced by an object reference: %#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) }))
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(http.StatusOK) }))
|
||||||
defer server.Close()
|
defer server.Close()
|
||||||
service := &Service{}
|
service := &Service{}
|
||||||
@@ -516,9 +566,9 @@ func TestUploadGeneratedRawMediaValueReplacesGeminiInlineDataWithAssetRef(t *tes
|
|||||||
if !ok {
|
if !ok {
|
||||||
t.Fatalf("inlineData.data should be an asset reference, got %+v", inlineData["data"])
|
t.Fatalf("inlineData.data should be an asset reference, got %+v", inlineData["data"])
|
||||||
}
|
}
|
||||||
ref, _ := data["assetRef"].(map[string]any)
|
upload, _ := data["upload"].(map[string]any)
|
||||||
if ref["sha256"] == "" || ref["contentType"] != "image/png" || ref["size"] != len(payload) {
|
if upload["sha256"] == "" || upload["contentType"] != "image/png" || upload["size"] != len(payload) || data["assetRef"] != nil {
|
||||||
t.Fatalf("unexpected asset ref: %+v", ref)
|
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") {
|
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)
|
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) }))
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(http.StatusOK) }))
|
||||||
defer server.Close()
|
defer server.Close()
|
||||||
service := &Service{}
|
service := &Service{}
|
||||||
@@ -551,7 +601,7 @@ func TestUploadGeneratedRawMediaValueReplacesBufferAndBytesWithAssetRefs(t *test
|
|||||||
next := uploaded.(map[string]any)
|
next := uploaded.(map[string]any)
|
||||||
for _, key := range []string{"buffer", "audio_bytes", "direct"} {
|
for _, key := range []string{"buffer", "audio_bytes", "direct"} {
|
||||||
item, ok := next[key].(map[string]any)
|
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])
|
t.Fatalf("%s was not objectified: %#v", key, next[key])
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -109,6 +109,11 @@ type Metrics struct {
|
|||||||
storageChannelFailovers atomic.Uint64
|
storageChannelFailovers atomic.Uint64
|
||||||
storageAllChannelsFailed atomic.Uint64
|
storageAllChannelsFailed atomic.Uint64
|
||||||
storageObjectifiedBytes atomic.Uint64
|
storageObjectifiedBytes atomic.Uint64
|
||||||
|
resultSourceUpstreamURL atomic.Uint64
|
||||||
|
resultSourceUploaded atomic.Uint64
|
||||||
|
resultPollResponses atomic.Uint64
|
||||||
|
resultPollResponseBytes atomic.Uint64
|
||||||
|
resultPollOversized atomic.Uint64
|
||||||
taskAdmissionWaitBuckets [11]atomic.Uint64
|
taskAdmissionWaitBuckets [11]atomic.Uint64
|
||||||
taskAdmissionWaitMicros 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) {
|
func (m *Metrics) ObserveTaskAdmissionWait(wait time.Duration) {
|
||||||
if wait < 0 {
|
if wait < 0 {
|
||||||
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_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_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())
|
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())
|
publicErrorCounters(w, publicerror.MetricSnapshot())
|
||||||
platformModelRateLimitUtilizationGauges(w, modelRateLimits)
|
platformModelRateLimitUtilizationGauges(w, modelRateLimits)
|
||||||
plainGauge(w, "easyai_gateway_postgres_pool_max_connections", "Maximum PostgreSQL connections in this process pool.", int64(postgresPool.MaxConnections))
|
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("retry", "s3", 0, 0)
|
||||||
metrics.ObserveObjectStorage("failover", "s3", 0, 0)
|
metrics.ObserveObjectStorage("failover", "s3", 0, 0)
|
||||||
metrics.ObserveObjectStorage("all_failed", "", 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.ObserveAsyncWorkerResize("success")
|
||||||
metrics.ObserveConcurrencyLeaseRenewal("success")
|
metrics.ObserveConcurrencyLeaseRenewal("success")
|
||||||
metrics.ObserveConcurrencyLeaseRenewal("lost")
|
metrics.ObserveConcurrencyLeaseRenewal("lost")
|
||||||
@@ -117,6 +121,11 @@ func TestMetricsExposeBoundedOutcomesAndState(t *testing.T) {
|
|||||||
`easyai_gateway_storage_channel_failovers_total 1`,
|
`easyai_gateway_storage_channel_failovers_total 1`,
|
||||||
`easyai_gateway_storage_all_channels_failed_total 1`,
|
`easyai_gateway_storage_all_channels_failed_total 1`,
|
||||||
`easyai_gateway_storage_objectified_bytes_total 128`,
|
`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_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_async_worker_resizes_total{outcome="success"} 1`,
|
||||||
`easyai_gateway_concurrency_lease_renewals_total{outcome="success"} 1`,
|
`easyai_gateway_concurrency_lease_renewals_total{outcome="success"} 1`,
|
||||||
|
|||||||
@@ -48,6 +48,8 @@ spec:
|
|||||||
- secretRef:
|
- secretRef:
|
||||||
name: easyai-ai-gateway-runtime
|
name: easyai-ai-gateway-runtime
|
||||||
env:
|
env:
|
||||||
|
- name: GOMEMLIMIT
|
||||||
|
value: 1536MiB
|
||||||
- name: AI_GATEWAY_PROCESS_ROLE
|
- name: AI_GATEWAY_PROCESS_ROLE
|
||||||
value: api
|
value: api
|
||||||
- name: AI_GATEWAY_ASYNC_QUEUE_WORKER_ENABLED
|
- name: AI_GATEWAY_ASYNC_QUEUE_WORKER_ENABLED
|
||||||
@@ -315,6 +317,8 @@ spec:
|
|||||||
- secretRef:
|
- secretRef:
|
||||||
name: easyai-ai-gateway-runtime
|
name: easyai-ai-gateway-runtime
|
||||||
env:
|
env:
|
||||||
|
- name: GOMEMLIMIT
|
||||||
|
value: 1536MiB
|
||||||
- name: AI_GATEWAY_PROCESS_ROLE
|
- name: AI_GATEWAY_PROCESS_ROLE
|
||||||
value: api
|
value: api
|
||||||
- name: AI_GATEWAY_ASYNC_QUEUE_WORKER_ENABLED
|
- name: AI_GATEWAY_ASYNC_QUEUE_WORKER_ENABLED
|
||||||
|
|||||||
Reference in New Issue
Block a user