fix(media): 对齐 EasyAI 媒体响应与转存策略
统一媒体异步提交和轮询响应,扩展 /ai/result 到当前用户的 Gateway 媒体任务,并保持既有 Gateway 字段兼容。\n\n启用转存但无可用渠道时回退到 24 小时本地静态资源;关闭转存时保留图片、音频等上游 Base64 字段。同步更新文件上传兼容结构、OpenAPI、管理端说明和回归测试。\n\n验证:cd apps/api && env -u AI_GATEWAY_TEST_DATABASE_URL go test ./... -count=1;gofmt -l 无输出;pnpm nx run web:typecheck。
This commit is contained in:
@@ -11,7 +11,7 @@ import "net/http"
|
||||
// @Security BearerAuth
|
||||
// @Param X-Async header bool false "true 时异步创建任务并返回 202"
|
||||
// @Param input body ImageVectorizeRequest true "图片矢量化请求"
|
||||
// @Success 200 {object} CompatibleResponse
|
||||
// @Success 200 {object} EasyAIGeneratedResponse
|
||||
// @Success 202 {object} TaskAcceptedResponse
|
||||
// @Failure 400 {object} ErrorEnvelope
|
||||
// @Failure 401 {object} ErrorEnvelope
|
||||
@@ -32,7 +32,7 @@ func (s *Server) createImageVectorizeTask() http.Handler {
|
||||
// @Security BearerAuth
|
||||
// @Param X-Async header bool false "true 时异步创建任务并返回 202"
|
||||
// @Param input body VideoUpscaleRequest true "视频超分请求"
|
||||
// @Success 200 {object} CompatibleResponse
|
||||
// @Success 200 {object} EasyAIGeneratedResponse
|
||||
// @Success 202 {object} TaskAcceptedResponse
|
||||
// @Failure 400 {object} ErrorEnvelope
|
||||
// @Failure 401 {object} ErrorEnvelope
|
||||
|
||||
@@ -487,6 +487,7 @@ VALUES ($1, 5, '{"purpose":"core-flow"}'::jsonb)`, inviteCode); err != nil {
|
||||
cancelReq.Header.Set("Content-Type", "application/json")
|
||||
cancelErrCh := make(chan error, 1)
|
||||
cancelTaskIDCh := make(chan string, 1)
|
||||
cancelRequestStartedAt := time.Now().UTC().Add(-time.Second)
|
||||
go func() {
|
||||
resp, err := http.DefaultClient.Do(cancelReq)
|
||||
if resp != nil {
|
||||
@@ -505,7 +506,7 @@ VALUES ($1, 5, '{"purpose":"core-flow"}'::jsonb)`, inviteCode); err != nil {
|
||||
t.Fatal("cancelled stream did not return response headers")
|
||||
}
|
||||
if cancelTaskID == "" {
|
||||
t.Fatal("cancelled stream response did not expose X-Gateway-Task-Id")
|
||||
cancelTaskID = waitForLatestTaskIDSince(t, ctx, testPool, "chat.completions", cancelRequestStartedAt, 2*time.Second)
|
||||
}
|
||||
cancelRequest()
|
||||
select {
|
||||
@@ -519,11 +520,20 @@ VALUES ($1, 5, '{"purpose":"core-flow"}'::jsonb)`, inviteCode); err != nil {
|
||||
}
|
||||
|
||||
var imageResponse struct {
|
||||
Task struct {
|
||||
Status string `json:"status"`
|
||||
LegacyTaskID string `json:"task_id"`
|
||||
TaskID string `json:"taskId"`
|
||||
QueryURL string `json:"query_url"`
|
||||
Task struct {
|
||||
ID string `json:"id"`
|
||||
Status string `json:"status"`
|
||||
Result map[string]any `json:"result"`
|
||||
} `json:"task"`
|
||||
Next struct {
|
||||
Detail string `json:"detail"`
|
||||
Events string `json:"events"`
|
||||
Result string `json:"result"`
|
||||
} `json:"next"`
|
||||
}
|
||||
doJSONWithHeaders(t, server.URL, http.MethodPost, "/api/v1/images/generations", apiKeyResponse.Secret, map[string]any{
|
||||
"model": defaultImageModel,
|
||||
@@ -534,14 +544,59 @@ VALUES ($1, 5, '{"purpose":"core-flow"}'::jsonb)`, inviteCode); err != nil {
|
||||
"simulation": true,
|
||||
"simulationDurationMs": 5,
|
||||
}, map[string]string{"X-Async": "true"}, http.StatusAccepted, &imageResponse)
|
||||
if imageResponse.Status != "submitted" ||
|
||||
imageResponse.TaskID == "" ||
|
||||
imageResponse.LegacyTaskID != imageResponse.TaskID ||
|
||||
imageResponse.Task.ID != imageResponse.TaskID ||
|
||||
imageResponse.QueryURL != "/api/v1/ai/result/"+imageResponse.TaskID ||
|
||||
imageResponse.Next.Result != imageResponse.QueryURL {
|
||||
t.Fatalf("unexpected EasyAI-compatible image submission: %+v", imageResponse)
|
||||
}
|
||||
waitForTaskStatus(t, server.URL, apiKeyResponse.Secret, imageResponse.Task.ID, []string{"succeeded"}, 10*time.Second)
|
||||
doJSON(t, server.URL, http.MethodGet, "/api/v1/tasks/"+imageResponse.Task.ID, apiKeyResponse.Secret, nil, http.StatusOK, &imageResponse.Task)
|
||||
if imageResponse.Task.Status != "succeeded" || imageResponse.Task.Result["id"] == "" {
|
||||
t.Fatalf("unexpected image generation task: %+v", imageResponse.Task)
|
||||
}
|
||||
var imageEasyAIResult struct {
|
||||
Status string `json:"status"`
|
||||
TaskID string `json:"task_id"`
|
||||
Data []map[string]any `json:"data"`
|
||||
Output []string `json:"output"`
|
||||
OutputContent []map[string]any `json:"output_content"`
|
||||
}
|
||||
doJSON(t, server.URL, http.MethodGet, imageResponse.QueryURL, apiKeyResponse.Secret, nil, http.StatusOK, &imageEasyAIResult)
|
||||
if imageEasyAIResult.Status != "success" ||
|
||||
imageEasyAIResult.TaskID != imageResponse.TaskID ||
|
||||
len(imageEasyAIResult.Data) == 0 ||
|
||||
len(imageEasyAIResult.Output) == 0 ||
|
||||
len(imageEasyAIResult.OutputContent) != len(imageEasyAIResult.Data) ||
|
||||
imageEasyAIResult.Data[0]["type"] != "image" {
|
||||
t.Fatalf("unexpected EasyAI-compatible image result: %+v", imageEasyAIResult)
|
||||
}
|
||||
var ordinaryLoginResponse struct {
|
||||
AccessToken string `json:"accessToken"`
|
||||
}
|
||||
doJSON(t, server.URL, http.MethodPost, "/api/v1/auth/login", "", map[string]any{
|
||||
"account": ordinaryUsername,
|
||||
"password": password,
|
||||
}, http.StatusOK, &ordinaryLoginResponse)
|
||||
var inaccessibleImageResult struct {
|
||||
Status string `json:"status"`
|
||||
Code string `json:"code"`
|
||||
Data []any `json:"data"`
|
||||
}
|
||||
doJSON(t, server.URL, http.MethodGet, imageResponse.QueryURL, ordinaryLoginResponse.AccessToken, nil, http.StatusNotFound, &inaccessibleImageResult)
|
||||
if inaccessibleImageResult.Status != "failed" ||
|
||||
inaccessibleImageResult.Code != "not_found" ||
|
||||
len(inaccessibleImageResult.Data) != 0 {
|
||||
t.Fatalf("EasyAI task result must stay owner-isolated: %+v", inaccessibleImageResult)
|
||||
}
|
||||
|
||||
var imageEditResponse struct {
|
||||
Task struct {
|
||||
Status string `json:"status"`
|
||||
LegacyTaskID string `json:"task_id"`
|
||||
TaskID string `json:"taskId"`
|
||||
Task struct {
|
||||
ID string `json:"id"`
|
||||
Status string `json:"status"`
|
||||
Result map[string]any `json:"result"`
|
||||
@@ -556,6 +611,12 @@ VALUES ($1, 5, '{"purpose":"core-flow"}'::jsonb)`, inviteCode); err != nil {
|
||||
"simulation": true,
|
||||
"simulationDurationMs": 5,
|
||||
}, map[string]string{"X-Async": "true"}, http.StatusAccepted, &imageEditResponse)
|
||||
if imageEditResponse.Status != "submitted" ||
|
||||
imageEditResponse.TaskID == "" ||
|
||||
imageEditResponse.LegacyTaskID != imageEditResponse.TaskID ||
|
||||
imageEditResponse.Task.ID != imageEditResponse.TaskID {
|
||||
t.Fatalf("unexpected EasyAI-compatible image edit submission: %+v", imageEditResponse)
|
||||
}
|
||||
waitForTaskStatus(t, server.URL, apiKeyResponse.Secret, imageEditResponse.Task.ID, []string{"succeeded"}, 10*time.Second)
|
||||
doJSON(t, server.URL, http.MethodGet, "/api/v1/tasks/"+imageEditResponse.Task.ID, apiKeyResponse.Secret, nil, http.StatusOK, &imageEditResponse.Task)
|
||||
if imageEditResponse.Task.Status != "succeeded" || imageEditResponse.Task.Result["id"] == "" {
|
||||
@@ -2069,16 +2130,17 @@ func doJSONWithHeaders(t *testing.T, baseURL string, method string, path string,
|
||||
|
||||
func doAPIV1ChatCompletionAndLoadTask(t *testing.T, ctx context.Context, pool *pgxpool.Pool, baseURL string, token string, payload map[string]any, marker string, expectedStatus int, responseOut any, taskDetailOut any) string {
|
||||
t.Helper()
|
||||
_ = ctx
|
||||
_ = pool
|
||||
_ = marker
|
||||
if responseOut == nil {
|
||||
responseOut = &map[string]any{}
|
||||
}
|
||||
requestStartedAt := time.Now().UTC().Add(-time.Second)
|
||||
responseHeaders := doJSONWithHeaders(t, baseURL, http.MethodPost, "/api/v1/chat/completions", token, payload, nil, expectedStatus, responseOut)
|
||||
taskID := strings.TrimSpace(responseHeaders.Get("X-Gateway-Task-Id"))
|
||||
if taskID == "" {
|
||||
t.Fatalf("chat completion response did not expose X-Gateway-Task-Id: headers=%v body=%+v", responseHeaders, responseOut)
|
||||
taskID = waitForLatestTaskIDSince(t, ctx, pool, "chat.completions", requestStartedAt, 2*time.Second)
|
||||
}
|
||||
if taskID == "" {
|
||||
t.Fatalf("load chat completion task %s failed: headers=%v body=%+v", marker, responseHeaders, responseOut)
|
||||
}
|
||||
if taskDetailOut != nil {
|
||||
doJSON(t, baseURL, http.MethodGet, "/api/v1/tasks/"+taskID, token, nil, http.StatusOK, taskDetailOut)
|
||||
@@ -2086,6 +2148,27 @@ func doAPIV1ChatCompletionAndLoadTask(t *testing.T, ctx context.Context, pool *p
|
||||
return taskID
|
||||
}
|
||||
|
||||
func waitForLatestTaskIDSince(t *testing.T, ctx context.Context, pool *pgxpool.Pool, kind string, since time.Time, timeout time.Duration) string {
|
||||
t.Helper()
|
||||
deadline := time.Now().Add(timeout)
|
||||
for time.Now().Before(deadline) {
|
||||
var taskID string
|
||||
err := pool.QueryRow(ctx, `
|
||||
SELECT id::text
|
||||
FROM gateway_tasks
|
||||
WHERE kind = $1
|
||||
AND created_at >= $2
|
||||
ORDER BY created_at DESC
|
||||
LIMIT 1`, kind, since).Scan(&taskID)
|
||||
if err == nil && taskID != "" {
|
||||
return taskID
|
||||
}
|
||||
time.Sleep(10 * time.Millisecond)
|
||||
}
|
||||
t.Fatalf("latest %s task was not persisted within %s", kind, timeout)
|
||||
return ""
|
||||
}
|
||||
|
||||
type taskWaitDetail struct {
|
||||
ID string `json:"id"`
|
||||
Status string `json:"status"`
|
||||
|
||||
@@ -0,0 +1,489 @@
|
||||
package httpapi
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/easyai/easyai-ai-gateway/apps/api/internal/runner"
|
||||
"github.com/easyai/easyai-ai-gateway/apps/api/internal/store"
|
||||
)
|
||||
|
||||
func easyAIAsyncMediaRequest(kind string, r *http.Request) bool {
|
||||
if r == nil || !asyncRequest(r) {
|
||||
return false
|
||||
}
|
||||
switch kind {
|
||||
case "images.generations",
|
||||
"images.edits",
|
||||
"images.vectorize",
|
||||
"videos.generations",
|
||||
"videos.upscales",
|
||||
"song.generations",
|
||||
"music.generations",
|
||||
"speech.generations",
|
||||
"voice.clone":
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func easyAITaskAcceptedResponse(task store.GatewayTask) map[string]any {
|
||||
resultPath := "/api/v1/ai/result/" + task.ID
|
||||
return map[string]any{
|
||||
"status": "submitted",
|
||||
"task_id": task.ID,
|
||||
"taskId": task.ID,
|
||||
"query_url": resultPath,
|
||||
"task": task,
|
||||
"next": map[string]string{
|
||||
"events": "/api/v1/tasks/" + task.ID + "/events",
|
||||
"detail": "/api/v1/tasks/" + task.ID,
|
||||
"result": resultPath,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func easyAITaskAcceptedResponseWithExtensions(task store.GatewayTask, extensions map[string]any) map[string]any {
|
||||
response := cloneEasyAIMap(extensions)
|
||||
for key, value := range easyAITaskAcceptedResponse(task) {
|
||||
response[key] = value
|
||||
}
|
||||
return response
|
||||
}
|
||||
|
||||
func writeEasyAIAsyncError(w http.ResponseWriter, status int, message string, details map[string]any, codes ...string) {
|
||||
code := ""
|
||||
if len(codes) > 0 {
|
||||
code = strings.TrimSpace(codes[0])
|
||||
}
|
||||
errorPayload := map[string]any{
|
||||
"message": message,
|
||||
"status": status,
|
||||
}
|
||||
if code != "" {
|
||||
errorPayload["code"] = code
|
||||
}
|
||||
for key, value := range details {
|
||||
errorPayload[key] = value
|
||||
}
|
||||
response := map[string]any{
|
||||
"status": "failed",
|
||||
"message": message,
|
||||
"data": []any{},
|
||||
"error": errorPayload,
|
||||
}
|
||||
if code != "" {
|
||||
response["code"] = code
|
||||
}
|
||||
writeJSON(w, status, response)
|
||||
}
|
||||
|
||||
func easyAISynchronousTaskResponse(task store.GatewayTask, output map[string]any) map[string]any {
|
||||
if task.Kind != "images.vectorize" {
|
||||
return output
|
||||
}
|
||||
response := cloneEasyAIMap(output)
|
||||
response["taskId"] = task.ID
|
||||
response["task_id"] = task.ID
|
||||
response["query_url"] = "/api/v1/ai/result/" + task.ID
|
||||
return response
|
||||
}
|
||||
|
||||
func easyAIFileUploadResponse(upload map[string]any) map[string]any {
|
||||
response := cloneEasyAIMap(upload)
|
||||
response["status"] = "success"
|
||||
response["data"] = cloneEasyAIMap(upload)
|
||||
return response
|
||||
}
|
||||
|
||||
func easyAITaskResultResponse(task store.GatewayTask) map[string]any {
|
||||
sourceResult := cloneEasyAIMap(task.Result)
|
||||
cleanResult := cloneEasyAIMap(sourceResult)
|
||||
delete(cleanResult, "raw")
|
||||
delete(cleanResult, "raw_data")
|
||||
normalizeEasyAIInlineMediaFields(cleanResult)
|
||||
|
||||
data := easyAITaskResultData(task, sourceResult)
|
||||
status := easyAITaskResultStatus(task.Status)
|
||||
if status == "failed" {
|
||||
data = []any{}
|
||||
}
|
||||
cleanResult["data"] = data
|
||||
cleanResult["output_content"] = data
|
||||
|
||||
response := cloneEasyAIMap(cleanResult)
|
||||
response["status"] = status
|
||||
response["task_id"] = task.ID
|
||||
response["taskId"] = task.ID
|
||||
response["query_url"] = "/api/v1/ai/result/" + task.ID
|
||||
response["created"] = task.CreatedAt.UnixMilli()
|
||||
response["data"] = data
|
||||
response["output_content"] = data
|
||||
response["output"] = easyAIOutputURLs(data)
|
||||
response["result"] = cleanResult
|
||||
|
||||
upstreamTaskID := firstNonEmpty(
|
||||
easyAIString(cleanResult["upstream_task_id"]),
|
||||
task.RemoteTaskID,
|
||||
)
|
||||
if upstreamTaskID != "" {
|
||||
response["upstream_task_id"] = upstreamTaskID
|
||||
}
|
||||
if len(task.Usage) > 0 && response["usage"] == nil {
|
||||
response["usage"] = task.Usage
|
||||
}
|
||||
|
||||
cancelState := runner.DescribeTaskCancellation(task)
|
||||
response["cancellable"] = cancelState.Cancellable
|
||||
response["submitted"] = cancelState.Submitted
|
||||
|
||||
message := firstNonEmpty(
|
||||
easyAIString(cleanResult["message"]),
|
||||
task.ErrorMessage,
|
||||
task.Error,
|
||||
task.Message,
|
||||
)
|
||||
if message == "" {
|
||||
switch status {
|
||||
case "submitted":
|
||||
message = "任务已提交,请稍后查看结果"
|
||||
case "process":
|
||||
message = "任务处理中"
|
||||
case "failed":
|
||||
message = "任务执行失败"
|
||||
}
|
||||
}
|
||||
if message != "" {
|
||||
response["message"] = message
|
||||
}
|
||||
if code := firstNonEmpty(task.ErrorCode, easyAIString(cleanResult["code"])); code != "" {
|
||||
response["code"] = code
|
||||
}
|
||||
return response
|
||||
}
|
||||
|
||||
func easyAITaskResultStatus(status string) string {
|
||||
switch strings.ToLower(strings.TrimSpace(status)) {
|
||||
case "succeeded", "success", "completed":
|
||||
return "success"
|
||||
case "failed", "cancelled", "canceled", "rejected":
|
||||
return "failed"
|
||||
case "running", "processing", "in_progress":
|
||||
return "process"
|
||||
default:
|
||||
return "submitted"
|
||||
}
|
||||
}
|
||||
|
||||
func easyAITaskResultData(task store.GatewayTask, result map[string]any) []any {
|
||||
for _, source := range easyAIResultSources(result) {
|
||||
if items := easyAIOutputArray(source); len(items) > 0 {
|
||||
return normalizeEasyAIOutputItems(task, items)
|
||||
}
|
||||
}
|
||||
if item := easyAIOutputMap(result); len(item) > 0 {
|
||||
normalizeEasyAIInlineMediaFields(item)
|
||||
if easyAIOutputURL(item) != "" ||
|
||||
easyAIOutputHasInlineMedia(item) ||
|
||||
(strings.EqualFold(easyAIString(item["type"]), "text") && (item["text"] != nil || item["content"] != nil)) {
|
||||
return normalizeEasyAIOutputItems(task, []any{item})
|
||||
}
|
||||
}
|
||||
return []any{}
|
||||
}
|
||||
|
||||
func easyAIResultSources(result map[string]any) []any {
|
||||
sources := []any{
|
||||
result["data"],
|
||||
result["output_content"],
|
||||
easyAIStructuredOutput(result["content"]),
|
||||
result["images"],
|
||||
result["videos"],
|
||||
result["audios"],
|
||||
result["files"],
|
||||
result["output"],
|
||||
}
|
||||
if raw, ok := result["raw"].(map[string]any); ok {
|
||||
sources = append(sources,
|
||||
raw["data"],
|
||||
raw["output_content"],
|
||||
easyAIStructuredOutput(raw["content"]),
|
||||
raw["images"],
|
||||
raw["videos"],
|
||||
raw["audios"],
|
||||
raw["files"],
|
||||
raw["output"],
|
||||
)
|
||||
}
|
||||
return sources
|
||||
}
|
||||
|
||||
func easyAIStructuredOutput(value any) any {
|
||||
switch value.(type) {
|
||||
case []any, []string, map[string]any:
|
||||
return value
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
func easyAIOutputArray(value any) []any {
|
||||
switch typed := value.(type) {
|
||||
case []any:
|
||||
return typed
|
||||
case []string:
|
||||
items := make([]any, 0, len(typed))
|
||||
for _, item := range typed {
|
||||
items = append(items, item)
|
||||
}
|
||||
return items
|
||||
case map[string]any, string:
|
||||
return []any{typed}
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
func normalizeEasyAIOutputItems(task store.GatewayTask, items []any) []any {
|
||||
normalized := make([]any, 0, len(items))
|
||||
for _, item := range items {
|
||||
output := easyAIOutputMap(item)
|
||||
if len(output) == 0 {
|
||||
continue
|
||||
}
|
||||
delete(output, "raw_data")
|
||||
normalizeEasyAIInlineMediaFields(output)
|
||||
|
||||
mediaURL := easyAIOutputURL(output)
|
||||
if mediaURL != "" {
|
||||
output["url"] = mediaURL
|
||||
}
|
||||
if strings.TrimSpace(easyAIString(output["type"])) == "" {
|
||||
if outputType := easyAIOutputType(task, output, mediaURL); outputType != "" {
|
||||
output["type"] = outputType
|
||||
}
|
||||
}
|
||||
normalized = append(normalized, output)
|
||||
}
|
||||
return normalized
|
||||
}
|
||||
|
||||
func easyAIOutputMap(value any) map[string]any {
|
||||
switch typed := value.(type) {
|
||||
case map[string]any:
|
||||
return cloneEasyAIMap(typed)
|
||||
case string:
|
||||
trimmed := strings.TrimSpace(typed)
|
||||
if trimmed == "" {
|
||||
return nil
|
||||
}
|
||||
if strings.HasPrefix(strings.ToLower(trimmed), "data:") {
|
||||
return map[string]any{"url": trimmed}
|
||||
}
|
||||
if strings.HasPrefix(trimmed, "/") ||
|
||||
strings.HasPrefix(strings.ToLower(trimmed), "http://") ||
|
||||
strings.HasPrefix(strings.ToLower(trimmed), "https://") {
|
||||
return map[string]any{"url": trimmed}
|
||||
}
|
||||
return map[string]any{"type": "text", "content": trimmed}
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
func easyAIOutputURL(item map[string]any) string {
|
||||
for _, key := range []string{
|
||||
"url",
|
||||
"generated_url",
|
||||
"generatedUrl",
|
||||
"output_url",
|
||||
"outputUrl",
|
||||
"download_url",
|
||||
"downloadUrl",
|
||||
"image_url",
|
||||
"video_url",
|
||||
"audio_url",
|
||||
} {
|
||||
if value := easyAINestedURL(item[key]); value != "" {
|
||||
return value
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func easyAINestedURL(value any) string {
|
||||
var candidate string
|
||||
switch typed := value.(type) {
|
||||
case string:
|
||||
candidate = strings.TrimSpace(typed)
|
||||
case map[string]any:
|
||||
candidate = strings.TrimSpace(easyAIString(typed["url"]))
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
if strings.HasPrefix(strings.ToLower(candidate), "data:") {
|
||||
return ""
|
||||
}
|
||||
return candidate
|
||||
}
|
||||
|
||||
func normalizeEasyAIInlineMediaFields(item map[string]any) {
|
||||
for _, key := range []string{
|
||||
"url",
|
||||
"generated_url",
|
||||
"generatedUrl",
|
||||
"output_url",
|
||||
"outputUrl",
|
||||
"download_url",
|
||||
"downloadUrl",
|
||||
"image_url",
|
||||
"video_url",
|
||||
"audio_url",
|
||||
} {
|
||||
value := easyAINestedURLAllowData(item[key])
|
||||
contentType, payload, ok := easyAIBase64DataURL(value)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
if strings.HasPrefix(contentType, "image/") {
|
||||
if easyAIString(item["b64_json"]) == "" {
|
||||
item["b64_json"] = payload
|
||||
}
|
||||
} else if easyAIString(item["content"]) == "" {
|
||||
item["content"] = value
|
||||
}
|
||||
if easyAIString(item["mime_type"]) == "" {
|
||||
item["mime_type"] = contentType
|
||||
}
|
||||
delete(item, key)
|
||||
}
|
||||
}
|
||||
|
||||
func easyAIBase64DataURL(value string) (string, string, bool) {
|
||||
value = strings.TrimSpace(value)
|
||||
if !strings.HasPrefix(strings.ToLower(value), "data:") {
|
||||
return "", "", false
|
||||
}
|
||||
meta, payload, ok := strings.Cut(value, ",")
|
||||
if !ok || strings.TrimSpace(payload) == "" {
|
||||
return "", "", false
|
||||
}
|
||||
parts := strings.Split(strings.TrimSpace(meta[5:]), ";")
|
||||
if len(parts) < 2 || !strings.EqualFold(strings.TrimSpace(parts[len(parts)-1]), "base64") {
|
||||
return "", "", false
|
||||
}
|
||||
contentType := strings.ToLower(strings.TrimSpace(parts[0]))
|
||||
if !strings.HasPrefix(contentType, "image/") &&
|
||||
!strings.HasPrefix(contentType, "audio/") &&
|
||||
!strings.HasPrefix(contentType, "video/") &&
|
||||
!strings.HasPrefix(contentType, "application/") {
|
||||
return "", "", false
|
||||
}
|
||||
return contentType, strings.TrimSpace(payload), true
|
||||
}
|
||||
|
||||
func easyAIOutputHasInlineMedia(item map[string]any) bool {
|
||||
if easyAIString(item["b64_json"]) != "" {
|
||||
return true
|
||||
}
|
||||
content := strings.ToLower(easyAIString(item["content"]))
|
||||
return strings.HasPrefix(content, "data:") && strings.Contains(content, ";base64,")
|
||||
}
|
||||
|
||||
func easyAINestedURLAllowData(value any) string {
|
||||
switch typed := value.(type) {
|
||||
case string:
|
||||
return strings.TrimSpace(typed)
|
||||
case map[string]any:
|
||||
return strings.TrimSpace(easyAIString(typed["url"]))
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
}
|
||||
|
||||
func easyAIOutputType(task store.GatewayTask, item map[string]any, mediaURL string) string {
|
||||
kind := strings.ToLower(strings.TrimSpace(task.Kind))
|
||||
switch {
|
||||
case kind == "images.vectorize":
|
||||
format := strings.ToLower(firstNonEmpty(
|
||||
easyAIString(item["format"]),
|
||||
easyAIString(task.Request["format"]),
|
||||
))
|
||||
if format == "svg" || format == "png" {
|
||||
return "image"
|
||||
}
|
||||
if format != "" {
|
||||
return "file"
|
||||
}
|
||||
case strings.HasPrefix(kind, "images."):
|
||||
return "image"
|
||||
case strings.HasPrefix(kind, "videos.") || kind == "video.upscale":
|
||||
return "video"
|
||||
case kind == "song.generations" || kind == "music.generations" || kind == "speech.generations":
|
||||
return "audio"
|
||||
}
|
||||
|
||||
lowerURL := strings.ToLower(mediaURL)
|
||||
if index := strings.IndexAny(lowerURL, "?#"); index >= 0 {
|
||||
lowerURL = lowerURL[:index]
|
||||
}
|
||||
switch {
|
||||
case strings.HasSuffix(lowerURL, ".svg"),
|
||||
strings.HasSuffix(lowerURL, ".png"),
|
||||
strings.HasSuffix(lowerURL, ".jpg"),
|
||||
strings.HasSuffix(lowerURL, ".jpeg"),
|
||||
strings.HasSuffix(lowerURL, ".webp"),
|
||||
strings.HasSuffix(lowerURL, ".gif"):
|
||||
return "image"
|
||||
case strings.HasSuffix(lowerURL, ".mp4"),
|
||||
strings.HasSuffix(lowerURL, ".mov"),
|
||||
strings.HasSuffix(lowerURL, ".webm"):
|
||||
return "video"
|
||||
case strings.HasSuffix(lowerURL, ".mp3"),
|
||||
strings.HasSuffix(lowerURL, ".wav"),
|
||||
strings.HasSuffix(lowerURL, ".m4a"),
|
||||
strings.HasSuffix(lowerURL, ".aac"),
|
||||
strings.HasSuffix(lowerURL, ".flac"):
|
||||
return "audio"
|
||||
case mediaURL != "":
|
||||
return "file"
|
||||
case item["text"] != nil || item["content"] != nil:
|
||||
return "text"
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
}
|
||||
|
||||
func easyAIOutputURLs(data []any) []string {
|
||||
output := make([]string, 0, len(data))
|
||||
for _, item := range data {
|
||||
if typed, ok := item.(map[string]any); ok {
|
||||
if mediaURL := easyAIOutputURL(typed); mediaURL != "" {
|
||||
output = append(output, mediaURL)
|
||||
}
|
||||
}
|
||||
}
|
||||
return output
|
||||
}
|
||||
|
||||
func easyAIString(value any) string {
|
||||
typed, _ := value.(string)
|
||||
return strings.TrimSpace(typed)
|
||||
}
|
||||
|
||||
func cloneEasyAIMap(source map[string]any) map[string]any {
|
||||
if len(source) == 0 {
|
||||
return map[string]any{}
|
||||
}
|
||||
raw, err := json.Marshal(source)
|
||||
if err != nil {
|
||||
return map[string]any{}
|
||||
}
|
||||
var result map[string]any
|
||||
if err := json.Unmarshal(raw, &result); err != nil {
|
||||
return map[string]any{}
|
||||
}
|
||||
return result
|
||||
}
|
||||
@@ -0,0 +1,311 @@
|
||||
package httpapi
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/easyai/easyai-ai-gateway/apps/api/internal/store"
|
||||
)
|
||||
|
||||
func TestEasyAITaskAcceptedResponseKeepsGatewayFieldsAndAddsLegacyFields(t *testing.T) {
|
||||
task := store.GatewayTask{
|
||||
ID: "task-accepted-1",
|
||||
Kind: "speech.generations",
|
||||
Status: "queued",
|
||||
AsyncMode: true,
|
||||
}
|
||||
|
||||
got := easyAITaskAcceptedResponse(task)
|
||||
if got["status"] != "submitted" || got["task_id"] != task.ID || got["taskId"] != task.ID {
|
||||
t.Fatalf("unexpected EasyAI submission identity: %+v", got)
|
||||
}
|
||||
if _, ok := got["task"].(store.GatewayTask); !ok {
|
||||
t.Fatalf("Gateway task envelope was removed: %+v", got)
|
||||
}
|
||||
next, _ := got["next"].(map[string]string)
|
||||
if next["detail"] != "/api/v1/tasks/"+task.ID ||
|
||||
next["events"] != "/api/v1/tasks/"+task.ID+"/events" ||
|
||||
next["result"] != "/api/v1/ai/result/"+task.ID {
|
||||
t.Fatalf("unexpected next links: %+v", next)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEasyAITaskResultStatus(t *testing.T) {
|
||||
for _, item := range []struct {
|
||||
input string
|
||||
want string
|
||||
}{
|
||||
{input: "queued", want: "submitted"},
|
||||
{input: "pending", want: "submitted"},
|
||||
{input: "running", want: "process"},
|
||||
{input: "processing", want: "process"},
|
||||
{input: "succeeded", want: "success"},
|
||||
{input: "completed", want: "success"},
|
||||
{input: "failed", want: "failed"},
|
||||
{input: "cancelled", want: "failed"},
|
||||
} {
|
||||
t.Run(item.input, func(t *testing.T) {
|
||||
if got := easyAITaskResultStatus(item.input); got != item.want {
|
||||
t.Fatalf("easyAITaskResultStatus(%q)=%q, want %q", item.input, got, item.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestEasyAITaskResultResponseNormalizesMediaOutputs(t *testing.T) {
|
||||
createdAt := time.Date(2026, 7, 23, 10, 0, 0, 0, time.UTC)
|
||||
for _, item := range []struct {
|
||||
name string
|
||||
task store.GatewayTask
|
||||
wantType string
|
||||
wantURL string
|
||||
wantCount int
|
||||
}{
|
||||
{
|
||||
name: "image alias",
|
||||
task: store.GatewayTask{
|
||||
ID: "image-1", Kind: "images.generations", Status: "succeeded", CreatedAt: createdAt,
|
||||
Result: map[string]any{"data": []any{map[string]any{
|
||||
"image_url": "https://example.com/output.png",
|
||||
}}},
|
||||
},
|
||||
wantType: "image", wantURL: "https://example.com/output.png", wantCount: 1,
|
||||
},
|
||||
{
|
||||
name: "video raw content",
|
||||
task: store.GatewayTask{
|
||||
ID: "video-1", Kind: "videos.generations", Status: "succeeded", CreatedAt: createdAt,
|
||||
Result: map[string]any{"raw": map[string]any{"content": map[string]any{
|
||||
"video_url": "https://example.com/output.mp4",
|
||||
"duration": 5,
|
||||
}}},
|
||||
},
|
||||
wantType: "video", wantURL: "https://example.com/output.mp4", wantCount: 1,
|
||||
},
|
||||
{
|
||||
name: "audio alias",
|
||||
task: store.GatewayTask{
|
||||
ID: "audio-1", Kind: "speech.generations", Status: "succeeded", CreatedAt: createdAt,
|
||||
Result: map[string]any{"data": []any{map[string]any{
|
||||
"audio_url": "https://example.com/output.wav",
|
||||
}}},
|
||||
},
|
||||
wantType: "audio", wantURL: "https://example.com/output.wav", wantCount: 1,
|
||||
},
|
||||
{
|
||||
name: "voice clone without media",
|
||||
task: store.GatewayTask{
|
||||
ID: "voice-1", Kind: "voice.clone", Status: "succeeded", CreatedAt: createdAt,
|
||||
Result: map[string]any{"voice_id": "voice-test-1", "cloned_voice": map[string]any{"voiceId": "voice-test-1"}},
|
||||
},
|
||||
wantCount: 0,
|
||||
},
|
||||
} {
|
||||
t.Run(item.name, func(t *testing.T) {
|
||||
got := easyAITaskResultResponse(item.task)
|
||||
if got["status"] != "success" || got["task_id"] != item.task.ID || got["created"] != createdAt.UnixMilli() {
|
||||
t.Fatalf("unexpected task result identity: %+v", got)
|
||||
}
|
||||
data, _ := got["data"].([]any)
|
||||
if len(data) != item.wantCount {
|
||||
t.Fatalf("unexpected output count: got=%d response=%+v", len(data), got)
|
||||
}
|
||||
outputContent, _ := got["output_content"].([]any)
|
||||
if len(outputContent) != item.wantCount {
|
||||
t.Fatalf("output_content is not synchronized: %+v", got)
|
||||
}
|
||||
if item.wantCount == 0 {
|
||||
if got["voice_id"] != "voice-test-1" {
|
||||
t.Fatalf("voice clone fields were lost: %+v", got)
|
||||
}
|
||||
return
|
||||
}
|
||||
output, _ := data[0].(map[string]any)
|
||||
if output["type"] != item.wantType || output["url"] != item.wantURL {
|
||||
t.Fatalf("unexpected normalized output: %+v", output)
|
||||
}
|
||||
if _, exposed := output["b64_json"]; exposed {
|
||||
t.Fatalf("base64 output was exposed: %+v", output)
|
||||
}
|
||||
urls, _ := got["output"].([]string)
|
||||
if len(urls) != 1 || urls[0] != item.wantURL {
|
||||
t.Fatalf("unexpected output URL list: %+v", got["output"])
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestEasyAITaskResultResponsePreservesBase64ImageOutput(t *testing.T) {
|
||||
base64Payload := "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAusB9Y9ZlB8AAAAASUVORK5CYII="
|
||||
task := store.GatewayTask{
|
||||
ID: "image-base64-1", Kind: "images.generations", Status: "succeeded",
|
||||
Result: map[string]any{"data": []any{map[string]any{
|
||||
"b64_json": base64Payload,
|
||||
"mime_type": "image/png",
|
||||
}}},
|
||||
}
|
||||
|
||||
got := easyAITaskResultResponse(task)
|
||||
data, _ := got["data"].([]any)
|
||||
if len(data) != 1 {
|
||||
t.Fatalf("unexpected base64 output count: %+v", got)
|
||||
}
|
||||
output, _ := data[0].(map[string]any)
|
||||
if output["type"] != "image" || output["b64_json"] != base64Payload || output["mime_type"] != "image/png" {
|
||||
t.Fatalf("base64 image output was not preserved: %+v", output)
|
||||
}
|
||||
if _, exposedAsURL := output["url"]; exposedAsURL {
|
||||
t.Fatalf("base64 image must not be exposed as a URL: %+v", output)
|
||||
}
|
||||
outputContent, _ := got["output_content"].([]any)
|
||||
content, _ := outputContent[0].(map[string]any)
|
||||
if content["b64_json"] != base64Payload {
|
||||
t.Fatalf("output_content lost base64 image: %+v", outputContent)
|
||||
}
|
||||
urls, _ := got["output"].([]string)
|
||||
if len(urls) != 0 {
|
||||
t.Fatalf("base64-only output must not create URL aliases: %+v", urls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEasyAITaskResultResponseMovesImageDataURLToB64JSON(t *testing.T) {
|
||||
base64Payload := "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAusB9Y9ZlB8AAAAASUVORK5CYII="
|
||||
task := store.GatewayTask{
|
||||
ID: "image-data-url-1", Kind: "images.generations", Status: "succeeded",
|
||||
Result: map[string]any{"data": []any{map[string]any{
|
||||
"url": "data:image/png;base64," + base64Payload,
|
||||
}}},
|
||||
}
|
||||
|
||||
got := easyAITaskResultResponse(task)
|
||||
data, _ := got["data"].([]any)
|
||||
output, _ := data[0].(map[string]any)
|
||||
if output["b64_json"] != base64Payload || output["mime_type"] != "image/png" || output["type"] != "image" {
|
||||
t.Fatalf("image data URL was not normalized: %+v", output)
|
||||
}
|
||||
if _, exists := output["url"]; exists {
|
||||
t.Fatalf("image data URL should move out of the URL field: %+v", output)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEasyAITaskResultResponsePreservesAudioDataURLAsContent(t *testing.T) {
|
||||
base64Payload := "SUQzBAAAAAAAI1RTU0UAAAAPAAADTGF2ZjYwLjMuMTAwAAAAAAAAAAAAAAD/"
|
||||
task := store.GatewayTask{
|
||||
ID: "audio-data-url-1", Kind: "speech.generations", Status: "succeeded",
|
||||
Result: map[string]any{"data": []any{map[string]any{
|
||||
"audio_url": "data:audio/mpeg;base64," + base64Payload,
|
||||
}}},
|
||||
}
|
||||
|
||||
got := easyAITaskResultResponse(task)
|
||||
data, _ := got["data"].([]any)
|
||||
output, _ := data[0].(map[string]any)
|
||||
if output["content"] != "data:audio/mpeg;base64,"+base64Payload ||
|
||||
output["mime_type"] != "audio/mpeg" ||
|
||||
output["type"] != "audio" {
|
||||
t.Fatalf("audio data URL was not normalized to inline content: %+v", output)
|
||||
}
|
||||
if _, exists := output["audio_url"]; exists {
|
||||
t.Fatalf("audio data URL should move out of the URL field: %+v", output)
|
||||
}
|
||||
urls, _ := got["output"].([]string)
|
||||
if len(urls) != 0 {
|
||||
t.Fatalf("base64-only audio must not create URL aliases: %+v", urls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEasyAITaskResultResponseMapsVectorFormats(t *testing.T) {
|
||||
for _, item := range []struct {
|
||||
format string
|
||||
wantType string
|
||||
}{
|
||||
{format: "svg", wantType: "image"},
|
||||
{format: "png", wantType: "image"},
|
||||
{format: "pdf", wantType: "file"},
|
||||
{format: "eps", wantType: "file"},
|
||||
} {
|
||||
t.Run(item.format, func(t *testing.T) {
|
||||
task := store.GatewayTask{
|
||||
ID: "vector-" + item.format, Kind: "images.vectorize", Status: "succeeded",
|
||||
Request: map[string]any{"format": item.format},
|
||||
Result: map[string]any{"data": []any{map[string]any{"url": "https://example.com/output." + item.format}}},
|
||||
}
|
||||
got := easyAITaskResultResponse(task)
|
||||
data, _ := got["data"].([]any)
|
||||
output, _ := data[0].(map[string]any)
|
||||
if output["type"] != item.wantType {
|
||||
t.Fatalf("format %s mapped to %v, want %s", item.format, output["type"], item.wantType)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestEasyAITaskResultResponseClearsFailedOutputs(t *testing.T) {
|
||||
task := store.GatewayTask{
|
||||
ID: "failed-1", Kind: "videos.upscales", Status: "cancelled",
|
||||
ErrorCode: "cancelled", ErrorMessage: "任务已取消",
|
||||
Result: map[string]any{"data": []any{map[string]any{
|
||||
"url": "https://example.com/partial.mp4",
|
||||
}}},
|
||||
}
|
||||
got := easyAITaskResultResponse(task)
|
||||
data, _ := got["data"].([]any)
|
||||
outputContent, _ := got["output_content"].([]any)
|
||||
if got["status"] != "failed" || got["code"] != "cancelled" || got["message"] != "任务已取消" ||
|
||||
len(data) != 0 || len(outputContent) != 0 {
|
||||
t.Fatalf("unexpected failed response: %+v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEasyAISynchronousVectorAndFileUploadResponsesAreAdditive(t *testing.T) {
|
||||
vectorTask := store.GatewayTask{ID: "vector-sync-1", Kind: "images.vectorize"}
|
||||
vector := easyAISynchronousTaskResponse(vectorTask, map[string]any{
|
||||
"status": "success",
|
||||
"data": []any{map[string]any{"url": "https://example.com/output.svg"}},
|
||||
})
|
||||
if vector["status"] != "success" || vector["taskId"] != vectorTask.ID || vector["task_id"] != vectorTask.ID {
|
||||
t.Fatalf("unexpected vector response: %+v", vector)
|
||||
}
|
||||
|
||||
upload := easyAIFileUploadResponse(map[string]any{
|
||||
"id": "file-1", "url": "https://example.com/upload.png", "filename": "upload.png",
|
||||
})
|
||||
data, _ := upload["data"].(map[string]any)
|
||||
if upload["status"] != "success" || upload["url"] != data["url"] || upload["id"] != data["id"] {
|
||||
t.Fatalf("unexpected upload response: %+v", upload)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEasyAIAsyncMediaRequestRequiresExplicitHeader(t *testing.T) {
|
||||
syncRequest := httptest.NewRequest(http.MethodPost, "/api/v1/images/generations", nil)
|
||||
if easyAIAsyncMediaRequest("images.generations", syncRequest) {
|
||||
t.Fatal("image request without X-Async must stay synchronous")
|
||||
}
|
||||
asyncRequest := httptest.NewRequest(http.MethodPost, "/api/v1/images/generations", nil)
|
||||
asyncRequest.Header.Set("X-Async", "true")
|
||||
if !easyAIAsyncMediaRequest("images.generations", asyncRequest) {
|
||||
t.Fatal("image request with X-Async must enable EasyAI async compatibility")
|
||||
}
|
||||
if easyAIAsyncMediaRequest("chat.completions", asyncRequest) {
|
||||
t.Fatal("chat completions must not enter EasyAI media async compatibility")
|
||||
}
|
||||
}
|
||||
|
||||
func TestWriteEasyAIAsyncErrorKeepsNestedGatewayError(t *testing.T) {
|
||||
recorder := httptest.NewRecorder()
|
||||
writeEasyAIAsyncError(recorder, http.StatusBadRequest, "invalid request", map[string]any{"param": "model"}, "invalid_parameter")
|
||||
|
||||
var got map[string]any
|
||||
if err := json.NewDecoder(recorder.Body).Decode(&got); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
errorPayload, _ := got["error"].(map[string]any)
|
||||
data, _ := got["data"].([]any)
|
||||
if got["status"] != "failed" || got["code"] != "invalid_parameter" ||
|
||||
errorPayload["message"] != "invalid request" || len(data) != 0 {
|
||||
t.Fatalf("unexpected async error response: %+v", got)
|
||||
}
|
||||
}
|
||||
@@ -62,7 +62,7 @@ func (s *Server) uploadFile(w http.ResponseWriter, r *http.Request) {
|
||||
writeError(w, status, err.Error())
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, upload)
|
||||
writeJSON(w, http.StatusOK, easyAIFileUploadResponse(upload))
|
||||
}
|
||||
|
||||
func firstNonEmptyFormValue(r *http.Request, key string, fallback string) string {
|
||||
|
||||
@@ -1039,15 +1039,14 @@ func (s *Server) listModelRateLimitStatuses(w http.ResponseWriter, r *http.Reque
|
||||
// @Failure 429 {object} ErrorEnvelope
|
||||
// @Failure 502 {object} ErrorEnvelope
|
||||
// @Router /api/v1/reranks [post]
|
||||
// @Router /api/v1/videos/generations [post]
|
||||
// @Router /api/v1/song/generations [post]
|
||||
// @Router /api/v1/music/generations [post]
|
||||
// @Router /api/v1/speech/generations [post]
|
||||
// @Router /api/v1/voice_clone [post]
|
||||
func (s *Server) createTask(kind string, compatible bool) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
targetProtocol := targetProtocolForTaskRequest(kind, r)
|
||||
writeTaskError := func(status int, message string, details map[string]any, codes ...string) {
|
||||
if easyAIAsyncMediaRequest(kind, r) {
|
||||
writeEasyAIAsyncError(w, status, message, details, codes...)
|
||||
return
|
||||
}
|
||||
if targetProtocol == "" {
|
||||
writeErrorWithDetails(w, status, message, details, codes...)
|
||||
return
|
||||
@@ -1093,9 +1092,10 @@ func (s *Server) createTask(kind string, compatible bool) http.Handler {
|
||||
return
|
||||
}
|
||||
responsePlan := planTaskResponse(kind, compatible, body, r)
|
||||
if targetProtocol != "" {
|
||||
if targetProtocol != "" && !easyAIAsyncMediaRequest(kind, r) {
|
||||
// OpenAI compatibility endpoints are synchronous APIs. Gateway async
|
||||
// task envelopes belong only to native Gateway routes.
|
||||
// task envelopes belong only to native Gateway routes, except for the
|
||||
// explicit EasyAI media X-Async extension.
|
||||
responsePlan.asyncMode = false
|
||||
}
|
||||
idempotencyKey, hasIdempotencyKey, err := optionalTaskIdempotencyKey(r)
|
||||
@@ -1150,7 +1150,7 @@ func (s *Server) createTask(kind string, compatible bool) http.Handler {
|
||||
writeTaskError(http.StatusConflict, "streaming idempotent replay is not supported", nil, "idempotency_stream_replay_unsupported")
|
||||
return
|
||||
}
|
||||
if targetProtocol != "" {
|
||||
if targetProtocol != "" && !responsePlan.asyncMode {
|
||||
if task.Status == "succeeded" {
|
||||
writeJSON(w, http.StatusOK, task.Result)
|
||||
return
|
||||
@@ -1274,7 +1274,7 @@ func openAIEmbeddingsDoc() {}
|
||||
// @Security BearerAuth
|
||||
// @Param input body TaskRequest true "OpenAI Images 请求"
|
||||
// @Param X-Async header bool false "true 时异步创建任务并返回 202"
|
||||
// @Success 200 {object} CompatibleResponse
|
||||
// @Success 200 {object} OpenAIImagesResponse
|
||||
// @Success 202 {object} TaskAcceptedResponse
|
||||
// @Failure 400 {object} OpenAIErrorEnvelope
|
||||
// @Failure 401 {object} OpenAIErrorEnvelope
|
||||
@@ -1284,6 +1284,32 @@ func openAIEmbeddingsDoc() {}
|
||||
// @Router /api/v1/images/edits [post]
|
||||
func openAIImagesDoc() {}
|
||||
|
||||
// easyAIMediaTasksDoc godoc
|
||||
// @Summary 创建 EasyAI 媒体任务
|
||||
// @Description 默认同步返回 EasyAI GeneratedResponse;设置 X-Async=true 时返回同时兼容 Gateway 与 server-main EasyAIClient 的异步提交结构。
|
||||
// @Tags media
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Security BearerAuth
|
||||
// @Param X-Async header bool false "true 时异步创建任务并返回 202"
|
||||
// @Param Idempotency-Key header string false "可选请求幂等键;同一用户范围内唯一"
|
||||
// @Param input body TaskRequest true "媒体任务请求,字段随任务类型变化"
|
||||
// @Success 200 {object} EasyAIGeneratedResponse
|
||||
// @Success 202 {object} TaskAcceptedResponse
|
||||
// @Failure 400 {object} ErrorEnvelope
|
||||
// @Failure 401 {object} ErrorEnvelope
|
||||
// @Failure 402 {object} ErrorEnvelope
|
||||
// @Failure 403 {object} ErrorEnvelope
|
||||
// @Failure 404 {object} ErrorEnvelope
|
||||
// @Failure 429 {object} ErrorEnvelope
|
||||
// @Failure 502 {object} ErrorEnvelope
|
||||
// @Router /api/v1/videos/generations [post]
|
||||
// @Router /api/v1/song/generations [post]
|
||||
// @Router /api/v1/music/generations [post]
|
||||
// @Router /api/v1/speech/generations [post]
|
||||
// @Router /api/v1/voice_clone [post]
|
||||
func easyAIMediaTasksDoc() {}
|
||||
|
||||
func (s *Server) requestExecutionContext(r *http.Request) (context.Context, context.CancelFunc) {
|
||||
base := context.WithoutCancel(r.Context())
|
||||
if s.ctx == nil {
|
||||
@@ -1418,7 +1444,7 @@ func writeProtocolCompatibleTaskResponse(runCtx context.Context, w http.Response
|
||||
writeWireResponse(w, result.Wire)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, result.Output)
|
||||
writeJSON(w, http.StatusOK, easyAISynchronousTaskResponse(result.Task, result.Output))
|
||||
}
|
||||
|
||||
func streamIncludeUsage(body map[string]any) bool {
|
||||
@@ -1461,14 +1487,7 @@ func streamCompatibleKind(kind string) bool {
|
||||
}
|
||||
|
||||
func writeTaskAccepted(w http.ResponseWriter, task store.GatewayTask) {
|
||||
writeJSON(w, http.StatusAccepted, map[string]any{
|
||||
"taskId": task.ID,
|
||||
"task": task,
|
||||
"next": map[string]string{
|
||||
"events": fmt.Sprintf("/api/v1/tasks/%s/events", task.ID),
|
||||
"detail": fmt.Sprintf("/api/v1/tasks/%s", task.ID),
|
||||
},
|
||||
})
|
||||
writeJSON(w, http.StatusAccepted, easyAITaskAcceptedResponse(task))
|
||||
}
|
||||
|
||||
func apiKeyScopeAllowed(user *auth.User, kind string) bool {
|
||||
|
||||
@@ -208,6 +208,17 @@ type FileUploadResponse struct {
|
||||
ContentType string `json:"contentType,omitempty" example:"image/png"`
|
||||
Size int `json:"size,omitempty" example:"1024"`
|
||||
AssetStorage map[string]interface{} `json:"assetStorage,omitempty"`
|
||||
Status string `json:"status" example:"success"`
|
||||
Data FileUploadData `json:"data"`
|
||||
}
|
||||
|
||||
type FileUploadData struct {
|
||||
ID string `json:"id,omitempty" example:"file_abc123"`
|
||||
URL string `json:"url,omitempty" example:"/static/uploaded/upload-abc123.png"`
|
||||
Filename string `json:"filename,omitempty" example:"image.png"`
|
||||
ContentType string `json:"contentType,omitempty" example:"image/png"`
|
||||
Size int `json:"size,omitempty" example:"1024"`
|
||||
AssetStorage map[string]interface{} `json:"assetStorage,omitempty"`
|
||||
}
|
||||
|
||||
type ReplacePlatformModelsRequest struct {
|
||||
@@ -215,9 +226,12 @@ type ReplacePlatformModelsRequest struct {
|
||||
}
|
||||
|
||||
type TaskAcceptedResponse struct {
|
||||
TaskID string `json:"taskId" example:"9f4d8f3d-5f5f-4bb7-a4be-344a9f930e25"`
|
||||
Task store.GatewayTask `json:"task"`
|
||||
Next TaskNextLinks `json:"next"`
|
||||
Status string `json:"status" example:"submitted"`
|
||||
LegacyTaskID string `json:"task_id" example:"9f4d8f3d-5f5f-4bb7-a4be-344a9f930e25"`
|
||||
TaskID string `json:"taskId" example:"9f4d8f3d-5f5f-4bb7-a4be-344a9f930e25"`
|
||||
QueryURL string `json:"query_url" example:"/api/v1/ai/result/9f4d8f3d-5f5f-4bb7-a4be-344a9f930e25"`
|
||||
Task store.GatewayTask `json:"task"`
|
||||
Next TaskNextLinks `json:"next"`
|
||||
}
|
||||
|
||||
type TaskCancelResponse struct {
|
||||
@@ -231,6 +245,56 @@ type TaskCancelResponse struct {
|
||||
type TaskNextLinks struct {
|
||||
Events string `json:"events" example:"/api/v1/tasks/9f4d8f3d-5f5f-4bb7-a4be-344a9f930e25/events"`
|
||||
Detail string `json:"detail" example:"/api/v1/tasks/9f4d8f3d-5f5f-4bb7-a4be-344a9f930e25"`
|
||||
Result string `json:"result" example:"/api/v1/ai/result/9f4d8f3d-5f5f-4bb7-a4be-344a9f930e25"`
|
||||
}
|
||||
|
||||
type EasyAIGeneratedResponse struct {
|
||||
Status string `json:"status" example:"success" enums:"submitted,process,success,failed"`
|
||||
TaskID string `json:"task_id" example:"9f4d8f3d-5f5f-4bb7-a4be-344a9f930e25"`
|
||||
CamelTaskID string `json:"taskId,omitempty" example:"9f4d8f3d-5f5f-4bb7-a4be-344a9f930e25"`
|
||||
UpstreamTaskID string `json:"upstream_task_id,omitempty" example:"provider-task-123"`
|
||||
QueryURL string `json:"query_url,omitempty" example:"/api/v1/ai/result/9f4d8f3d-5f5f-4bb7-a4be-344a9f930e25"`
|
||||
Created int64 `json:"created" example:"1784772000000"`
|
||||
Message string `json:"message,omitempty"`
|
||||
Code string `json:"code,omitempty"`
|
||||
Data []EasyAIMediaOutput `json:"data"`
|
||||
Output []string `json:"output"`
|
||||
OutputContent []EasyAIMediaOutput `json:"output_content"`
|
||||
Cancellable bool `json:"cancellable"`
|
||||
Submitted bool `json:"submitted"`
|
||||
Result map[string]interface{} `json:"result,omitempty"`
|
||||
Usage map[string]interface{} `json:"usage,omitempty"`
|
||||
}
|
||||
|
||||
type EasyAIMediaOutput struct {
|
||||
Type string `json:"type,omitempty" example:"video" enums:"image,video,audio,file,text"`
|
||||
URL string `json:"url,omitempty" example:"https://cdn.example.com/output.mp4"`
|
||||
// B64JSON is retained when result media transfer is disabled.
|
||||
B64JSON string `json:"b64_json,omitempty"`
|
||||
ImageURL string `json:"image_url,omitempty"`
|
||||
VideoURL string `json:"video_url,omitempty"`
|
||||
AudioURL string `json:"audio_url,omitempty"`
|
||||
Content string `json:"content,omitempty"`
|
||||
Text string `json:"text,omitempty"`
|
||||
MimeType string `json:"mime_type,omitempty"`
|
||||
Format string `json:"format,omitempty"`
|
||||
Duration float64 `json:"duration,omitempty"`
|
||||
Width int `json:"width,omitempty"`
|
||||
Height int `json:"height,omitempty"`
|
||||
SourceResolution string `json:"source_resolution,omitempty"`
|
||||
TargetResolution string `json:"target_resolution,omitempty"`
|
||||
Metadata map[string]interface{} `json:"metadata,omitempty"`
|
||||
}
|
||||
|
||||
type OpenAIImagesResponse struct {
|
||||
Created int64 `json:"created" example:"1710000000"`
|
||||
Data []OpenAIImageData `json:"data"`
|
||||
}
|
||||
|
||||
type OpenAIImageData struct {
|
||||
URL string `json:"url,omitempty" example:"https://cdn.example.com/output.png"`
|
||||
B64JSON string `json:"b64_json,omitempty"`
|
||||
RevisedPrompt string `json:"revised_prompt,omitempty"`
|
||||
}
|
||||
|
||||
type TaskAcceptedEvent struct {
|
||||
|
||||
@@ -268,7 +268,7 @@ func NewServerWithContext(ctx context.Context, cfg config.Config, db *store.Stor
|
||||
mux.Handle("POST /api/v1/videos/upscales", server.requireUser(auth.PermissionBasic, server.createVideoUpscaleTask()))
|
||||
mux.Handle("POST /api/v1/video/upscale", server.requireUser(auth.PermissionBasic, server.createVideoUpscaleTask()))
|
||||
mux.Handle("POST /api/v1/video/generations", server.requireUser(auth.PermissionBasic, http.HandlerFunc(server.createLegacyVolcesVideoGeneration)))
|
||||
mux.Handle("GET /api/v1/ai/result/{taskID}", server.requireUser(auth.PermissionBasic, http.HandlerFunc(server.getLegacyVolcesVideoResult)))
|
||||
mux.Handle("GET /api/v1/ai/result/{taskID}", server.requireUser(auth.PermissionBasic, http.HandlerFunc(server.getEasyAITaskResult)))
|
||||
mux.Handle("POST /api/v1/song/generations", server.requireUser(auth.PermissionBasic, server.createTask("song.generations", true)))
|
||||
mux.Handle("POST /api/v1/music/generations", server.requireUser(auth.PermissionBasic, server.createTask("music.generations", true)))
|
||||
mux.Handle("POST /api/v1/speech/generations", server.requireUser(auth.PermissionBasic, server.createTask("speech.generations", true)))
|
||||
|
||||
@@ -1,8 +1,12 @@
|
||||
package httpapi
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"github.com/easyai/easyai-ai-gateway/apps/api/internal/store"
|
||||
)
|
||||
|
||||
func TestOptionalTaskIdempotencyKeyRejectsMultipleValues(t *testing.T) {
|
||||
@@ -29,3 +33,29 @@ func TestTaskIdempotencyRequestHashIsCanonical(t *testing.T) {
|
||||
t.Fatal("async response semantics must be part of the request hash")
|
||||
}
|
||||
}
|
||||
|
||||
func TestWriteIdempotentAsyncTaskReplayKeepsEasyAICompatibilityFields(t *testing.T) {
|
||||
t.Parallel()
|
||||
task := store.GatewayTask{
|
||||
ID: "async-replay-1",
|
||||
Kind: "images.generations",
|
||||
Status: "queued",
|
||||
AsyncMode: true,
|
||||
}
|
||||
recorder := httptest.NewRecorder()
|
||||
|
||||
writeIdempotentTaskReplay(recorder, task, true)
|
||||
|
||||
if recorder.Code != http.StatusAccepted ||
|
||||
recorder.Header().Get("Idempotent-Replayed") != "true" ||
|
||||
recorder.Header().Get("X-Gateway-Task-Id") != task.ID {
|
||||
t.Fatalf("unexpected replay status/headers: code=%d headers=%v", recorder.Code, recorder.Header())
|
||||
}
|
||||
var got map[string]any
|
||||
if err := json.NewDecoder(recorder.Body).Decode(&got); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got["status"] != "submitted" || got["task_id"] != task.ID || got["taskId"] != task.ID {
|
||||
t.Fatalf("unexpected replay body: %+v", got)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -143,11 +143,11 @@ func (s *Server) deleteVolcesContentsGenerationTask(w http.ResponseWriter, r *ht
|
||||
// createLegacyVolcesVideoGeneration godoc
|
||||
// @Summary 创建 server-main 兼容视频任务
|
||||
// @Description 兼容 server-main 的 /api/v1/video/generations,返回 submitted 和 task_id;额外保留火山任务字段。
|
||||
// @Tags volces-compatible
|
||||
// @Tags media
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Security BearerAuth
|
||||
// @Success 200 {object} map[string]any
|
||||
// @Success 200 {object} TaskAcceptedResponse
|
||||
// @Router /api/v1/video/generations [post]
|
||||
func (s *Server) createLegacyVolcesVideoGeneration(w http.ResponseWriter, r *http.Request) {
|
||||
user, ok := auth.UserFromContext(r.Context())
|
||||
@@ -166,35 +166,40 @@ func (s *Server) createLegacyVolcesVideoGeneration(w http.ResponseWriter, r *htt
|
||||
return
|
||||
}
|
||||
response := volcesCompatibleTask(task)
|
||||
response["status"] = "submitted"
|
||||
response["task_id"] = task.ID
|
||||
writeJSON(w, http.StatusOK, response)
|
||||
writeJSON(w, http.StatusOK, easyAITaskAcceptedResponseWithExtensions(task, response))
|
||||
}
|
||||
|
||||
// getLegacyVolcesVideoResult godoc
|
||||
// @Summary 查询 server-main 兼容视频结果
|
||||
// @Tags volces-compatible
|
||||
// getEasyAITaskResult godoc
|
||||
// @Summary 查询 server-main EasyAIClient 兼容任务结果
|
||||
// @Description 查询当前用户的图片、视频、音频、视频高清和矢量化任务,并返回 EasyAI GeneratedResponse 兼容结构。
|
||||
// @Tags tasks
|
||||
// @Produce json
|
||||
// @Security BearerAuth
|
||||
// @Param taskID path string true "任务 ID"
|
||||
// @Success 200 {object} map[string]any
|
||||
// @Success 200 {object} EasyAIGeneratedResponse
|
||||
// @Failure 404 {object} EasyAIGeneratedResponse
|
||||
// @Router /api/v1/ai/result/{taskID} [get]
|
||||
func (s *Server) getLegacyVolcesVideoResult(w http.ResponseWriter, r *http.Request) {
|
||||
task, ok := s.volcesCompatibleTaskForUser(w, r)
|
||||
if !ok {
|
||||
func (s *Server) getEasyAITaskResult(w http.ResponseWriter, r *http.Request) {
|
||||
user, ok := auth.UserFromContext(r.Context())
|
||||
if !ok || user == nil {
|
||||
writeEasyAIAsyncError(w, http.StatusUnauthorized, "unauthorized", nil, "unauthorized")
|
||||
return
|
||||
}
|
||||
compat := volcesCompatibleTask(task)
|
||||
legacyStatus := "process"
|
||||
switch compat["status"] {
|
||||
case "succeeded":
|
||||
legacyStatus = "success"
|
||||
case "failed", "cancelled":
|
||||
legacyStatus = "failed"
|
||||
task, err := s.store.GetTask(r.Context(), strings.TrimSpace(r.PathValue("taskID")))
|
||||
if err != nil {
|
||||
if store.IsNotFound(err) {
|
||||
writeEasyAIAsyncError(w, http.StatusNotFound, "task not found", nil, "not_found")
|
||||
return
|
||||
}
|
||||
s.logger.Error("get EasyAI-compatible task failed", "error", err)
|
||||
writeError(w, http.StatusInternalServerError, "get task failed", "internal_error")
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{
|
||||
"status": legacyStatus, "task_id": task.ID, "data": compat["content"], "result": compat,
|
||||
})
|
||||
if !runner.TaskAccessibleToUser(task, user) {
|
||||
writeEasyAIAsyncError(w, http.StatusNotFound, "task not found", nil, "not_found")
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, easyAITaskResultResponse(task))
|
||||
}
|
||||
|
||||
func (s *Server) createVolcesCompatibleTask(r *http.Request, user *auth.User, body map[string]any) (store.GatewayTask, error) {
|
||||
|
||||
Reference in New Issue
Block a user