From ada765d90e625dd4f2d31f9ab175b1d4e828016b Mon Sep 17 00:00:00 2001 From: wangbo Date: Mon, 11 May 2026 00:39:19 +0800 Subject: [PATCH] feat: improve simulation media tasks --- apps/api/internal/clients/clients_test.go | 86 +++++++ apps/api/internal/clients/simulation.go | 224 ++++++++++++++++-- .../httpapi/core_flow_integration_test.go | 44 ++-- apps/api/internal/httpapi/server.go | 1 + .../api/internal/httpapi/simulation_assets.go | 49 ++++ 5 files changed, 370 insertions(+), 34 deletions(-) create mode 100644 apps/api/internal/httpapi/simulation_assets.go diff --git a/apps/api/internal/clients/clients_test.go b/apps/api/internal/clients/clients_test.go index bbf132e..c115a48 100644 --- a/apps/api/internal/clients/clients_test.go +++ b/apps/api/internal/clients/clients_test.go @@ -7,10 +7,96 @@ import ( "net/http/httptest" "strings" "testing" + "time" "github.com/easyai/easyai-ai-gateway/apps/api/internal/store" ) +func TestSimulationClientReturnsImageDemoAssets(t *testing.T) { + response, err := (SimulationClient{}).Run(context.Background(), Request{ + Kind: "images.generations", + Model: "gpt-image-1", + Body: map[string]any{ + "prompt": "demo image", + "n": 2, + "simulationDurationMs": 5, + }, + Candidate: store.RuntimeModelCandidate{Provider: "simulation"}, + }) + if err != nil { + t.Fatalf("run simulation image client: %v", err) + } + data, _ := response.Result["data"].([]any) + if len(data) != 2 || response.ResponseDurationMS <= 0 { + t.Fatalf("unexpected simulated image response: %+v duration=%d", response.Result, response.ResponseDurationMS) + } + item, _ := data[0].(map[string]any) + if item["url"] != "/static/simulation/image.svg" || item["assetSource"] != "simulation" { + t.Fatalf("unexpected simulated image item: %+v", item) + } +} + +func TestSimulationClientReturnsVideoDemoAssets(t *testing.T) { + response, err := (SimulationClient{}).Run(context.Background(), Request{ + Kind: "videos.generations", + ModelType: "video_generate", + Model: "demo-video-model", + Body: map[string]any{ + "prompt": "demo video", + "count": 2, + "duration": 6, + "simulationDurationMs": 5, + }, + Candidate: store.RuntimeModelCandidate{Provider: "simulation"}, + }) + if err != nil { + t.Fatalf("run simulation video client: %v", err) + } + data, _ := response.Result["data"].([]any) + if len(data) != 2 || response.ResponseDurationMS <= 0 { + t.Fatalf("unexpected simulated video response: %+v duration=%d", response.Result, response.ResponseDurationMS) + } + item, _ := data[0].(map[string]any) + if item["video_url"] != "/static/simulation/video.mp4" || item["url"] != "/static/simulation/video.mp4" || item["poster"] != "/static/simulation/video-poster.svg" { + t.Fatalf("unexpected simulated video item: %+v", item) + } + if item["duration"] != 6 || item["assetSource"] != "simulation" { + t.Fatalf("unexpected simulated video metadata: %+v", item) + } +} + +func TestSimulationDurationDefaultsByMediaType(t *testing.T) { + imageDuration := simulationDuration(Request{Kind: "images.generations"}) + if imageDuration < 10*time.Second || imageDuration > 30*time.Second { + t.Fatalf("image simulation duration should default to 10-30s, got %s", imageDuration) + } + videoDuration := simulationDuration(Request{Kind: "videos.generations"}) + if videoDuration < 2*time.Minute || videoDuration > 3*time.Minute { + t.Fatalf("video simulation duration should default to 2-3m, got %s", videoDuration) + } + textDuration := simulationDuration(Request{Kind: "chat.completions"}) + if textDuration < 800*time.Millisecond || textDuration > 2400*time.Millisecond { + t.Fatalf("text simulation duration should keep short defaults, got %s", textDuration) + } +} + +func TestSimulationDurationCanBeControlledByParams(t *testing.T) { + fixedDuration := simulationDuration(Request{Body: map[string]any{"simulationDurationSeconds": 7}}) + if fixedDuration != 7*time.Second { + t.Fatalf("simulationDurationSeconds should set fixed duration, got %s", fixedDuration) + } + rangeDuration := simulationDuration(Request{ + Kind: "videos.generations", + Body: map[string]any{ + "simulationMinDurationSeconds": 1, + "simulationMaxDurationSeconds": 1, + }, + }) + if rangeDuration != time.Second { + t.Fatalf("simulation duration range params should override video defaults, got %s", rangeDuration) + } +} + func TestOpenAIClientChatContract(t *testing.T) { var gotPath string var gotAuth string diff --git a/apps/api/internal/clients/simulation.go b/apps/api/internal/clients/simulation.go index c4c2b3d..f6521be 100644 --- a/apps/api/internal/clients/simulation.go +++ b/apps/api/internal/clients/simulation.go @@ -3,24 +3,60 @@ package clients import ( "context" "fmt" + "math/rand" "strings" "time" ) type SimulationClient struct{} +const ( + defaultSimulationTextMinDuration = 800 * time.Millisecond + defaultSimulationTextMaxDuration = 2400 * time.Millisecond + defaultSimulationImageMinDuration = 10 * time.Second + defaultSimulationImageMaxDuration = 30 * time.Second + defaultSimulationVideoMinDuration = 2 * time.Minute + defaultSimulationVideoMaxDuration = 3 * time.Minute + maxSimulationDuration = 10 * time.Minute +) + func (c SimulationClient) Run(ctx context.Context, request Request) (Response, error) { - _ = ctx profile := simulationProfile(request) + responseStartedAt := time.Now() + duration := simulationDuration(request) + if duration > 0 { + timer := time.NewTimer(duration) + select { + case <-ctx.Done(): + timer.Stop() + return Response{}, ctx.Err() + case <-timer.C: + } + } + responseFinishedAt := time.Now() if profile == "retryable_failure" { - return Response{}, &ClientError{Code: "server_error", Message: "simulated retryable failure", Retryable: true} + return Response{}, &ClientError{ + Code: "server_error", + Message: "simulated retryable failure", + RequestID: "simulated-request", + ResponseStartedAt: responseStartedAt, + ResponseFinishedAt: responseFinishedAt, + ResponseDurationMS: responseDurationMS(responseStartedAt, responseFinishedAt), + Retryable: true, + } } if profile == "fatal_failure" || profile == "non_retryable_failure" { - return Response{}, &ClientError{Code: "bad_request", Message: "simulated non-retryable failure", Retryable: false} + return Response{}, &ClientError{ + Code: "bad_request", + Message: "simulated non-retryable failure", + RequestID: "simulated-request", + ResponseStartedAt: responseStartedAt, + ResponseFinishedAt: responseFinishedAt, + ResponseDurationMS: responseDurationMS(responseStartedAt, responseFinishedAt), + Retryable: false, + } } - responseStartedAt := time.Now() result := simulatedResult(request) - responseFinishedAt := time.Now() return Response{ Result: result, RequestID: requestIDFromResult(result), @@ -80,24 +116,73 @@ func simulatedResult(request Request) map[string]any { "id": "img-edit-simulated", "created": nowUnix(), "model": request.Model, - "data": []any{map[string]any{ - "url": "/static/simulation/image-edit.png", - "revised_prompt": firstNonEmptyPrompt(request.Body, "simulation image edit"), - }}, + "data": simulatedImageData(request, "/static/simulation/image-edit.svg", "simulation image edit"), } - default: + case "images.generations": return map[string]any{ "id": "img-simulated", "created": nowUnix(), "model": request.Model, - "data": []any{map[string]any{ - "url": "/static/simulation/image.png", - "revised_prompt": firstNonEmptyPrompt(request.Body, "simulation image"), - }}, + "data": simulatedImageData(request, "/static/simulation/image.svg", "simulation image"), + } + case "videos.generations": + return map[string]any{ + "id": "video-simulated", + "created": nowUnix(), + "model": request.Model, + "data": simulatedVideoData(request), + } + default: + modelType := strings.ToLower(request.ModelType) + kind := strings.ToLower(request.Kind) + if strings.Contains(modelType, "video") || strings.Contains(kind, "video") { + return map[string]any{ + "id": "video-simulated", + "created": nowUnix(), + "model": request.Model, + "data": simulatedVideoData(request), + } + } + return map[string]any{ + "id": "img-simulated", + "created": nowUnix(), + "model": request.Model, + "data": simulatedImageData(request, "/static/simulation/image.svg", "simulation image"), } } } +func simulatedImageData(request Request, url string, fallbackPrompt string) []any { + count := simulatedOutputCount(request.Body) + items := make([]any, 0, count) + for index := 0; index < count; index += 1 { + items = append(items, map[string]any{ + "url": url, + "assetSource": "simulation", + "index": index, + "revised_prompt": firstNonEmptyPrompt(request.Body, fallbackPrompt), + }) + } + return items +} + +func simulatedVideoData(request Request) []any { + count := simulatedOutputCount(request.Body) + items := make([]any, 0, count) + for index := 0; index < count; index += 1 { + items = append(items, map[string]any{ + "url": "/static/simulation/video.mp4", + "video_url": "/static/simulation/video.mp4", + "poster": "/static/simulation/video-poster.svg", + "duration": simulatedVideoDurationSeconds(request), + "assetSource": "simulation", + "index": index, + "revised_prompt": firstNonEmptyPrompt(request.Body, "simulation video"), + }) + } + return items +} + func simulatedUsage(request Request) Usage { if request.ModelType == "chat" || request.Kind == "responses" { return Usage{InputTokens: 12, OutputTokens: 8, TotalTokens: 20} @@ -117,6 +202,117 @@ func simulatedProgress(request Request) []Progress { } } +func simulationDuration(request Request) time.Duration { + if fixedMS := simulationDurationMS(request, "simulationDurationMs", "testDurationMs"); fixedMS >= 0 { + return clampSimulationDuration(time.Duration(fixedMS) * time.Millisecond) + } + if fixedSeconds := simulationDurationSeconds(request, "simulationDurationSeconds", "testDurationSeconds"); fixedSeconds >= 0 { + return clampSimulationDuration(time.Duration(fixedSeconds) * time.Second) + } + minDuration, maxDuration := defaultSimulationDurationRange(request) + if minMS := simulationDurationMS(request, "simulationMinDurationMs", "simulationDurationMinMs", "testMinDurationMs", "testDurationMinMs"); minMS >= 0 { + minDuration = time.Duration(minMS) * time.Millisecond + } + if maxMS := simulationDurationMS(request, "simulationMaxDurationMs", "simulationDurationMaxMs", "testMaxDurationMs", "testDurationMaxMs"); maxMS >= 0 { + maxDuration = time.Duration(maxMS) * time.Millisecond + } + if minSeconds := simulationDurationSeconds(request, "simulationMinDurationSeconds", "simulationDurationMinSeconds", "testMinDurationSeconds", "testDurationMinSeconds"); minSeconds >= 0 { + minDuration = time.Duration(minSeconds) * time.Second + } + if maxSeconds := simulationDurationSeconds(request, "simulationMaxDurationSeconds", "simulationDurationMaxSeconds", "testMaxDurationSeconds", "testDurationMaxSeconds"); maxSeconds >= 0 { + maxDuration = time.Duration(maxSeconds) * time.Second + } + minDuration = clampSimulationDuration(minDuration) + maxDuration = clampSimulationDuration(maxDuration) + if maxDuration < minDuration { + maxDuration = minDuration + } + spread := maxDuration - minDuration + if spread <= 0 { + return minDuration + } + return minDuration + time.Duration(rand.Int63n(int64(spread)+1)) +} + +func defaultSimulationDurationRange(request Request) (time.Duration, time.Duration) { + if simulationVideoRequest(request) { + return defaultSimulationVideoMinDuration, defaultSimulationVideoMaxDuration + } + if simulationImageRequest(request) { + return defaultSimulationImageMinDuration, defaultSimulationImageMaxDuration + } + return defaultSimulationTextMinDuration, defaultSimulationTextMaxDuration +} + +func simulationVideoRequest(request Request) bool { + kind := strings.ToLower(request.Kind) + modelType := strings.ToLower(request.ModelType) + return strings.Contains(kind, "video") || strings.Contains(modelType, "video") +} + +func simulationImageRequest(request Request) bool { + kind := strings.ToLower(request.Kind) + modelType := strings.ToLower(request.ModelType) + return strings.Contains(kind, "image") || strings.Contains(modelType, "image") +} + +func simulationDurationSeconds(request Request, keys ...string) int { + for _, source := range []map[string]any{request.Body, request.Candidate.PlatformConfig, request.Candidate.Credentials} { + for _, key := range keys { + value := intValue(source, key, -1) + if value >= 0 { + return value + } + } + } + return -1 +} + +func simulationDurationMS(request Request, keys ...string) int { + for _, source := range []map[string]any{request.Body, request.Candidate.PlatformConfig, request.Candidate.Credentials} { + for _, key := range keys { + value := intValue(source, key, -1) + if value >= 0 { + return value + } + } + } + return -1 +} + +func clampSimulationDuration(duration time.Duration) time.Duration { + if duration < 0 { + return 0 + } + if duration > maxSimulationDuration { + return maxSimulationDuration + } + return duration +} + +func simulatedOutputCount(body map[string]any) int { + count := intValue(body, "n", 0) + if count <= 0 { + count = intValue(body, "count", 0) + } + if count <= 0 { + return 1 + } + if count > 20 { + return 20 + } + return count +} + +func simulatedVideoDurationSeconds(request Request) int { + for _, key := range []string{"duration", "duration_seconds", "durationSeconds"} { + if value := intValue(request.Body, key, 0); value > 0 { + return value + } + } + return 5 +} + func firstNonEmptyPrompt(body map[string]any, fallback string) string { for _, key := range []string{"prompt", "input"} { if value := strings.TrimSpace(stringValue(body, key)); value != "" { diff --git a/apps/api/internal/httpapi/core_flow_integration_test.go b/apps/api/internal/httpapi/core_flow_integration_test.go index 11e2c44..26f42f5 100644 --- a/apps/api/internal/httpapi/core_flow_integration_test.go +++ b/apps/api/internal/httpapi/core_flow_integration_test.go @@ -248,10 +248,11 @@ VALUES ($1, 5, '{"purpose":"core-flow"}'::jsonb)`, inviteCode); err != nil { } `json:"task"` } doJSON(t, server.URL, http.MethodPost, "/api/v1/chat/completions", apiKeyResponse.Secret, map[string]any{ - "model": "gpt-4o-mini", - "runMode": "simulation", - "simulation": true, - "messages": []map[string]any{{"role": "user", "content": "ping"}}, + "model": "gpt-4o-mini", + "runMode": "simulation", + "simulation": true, + "simulationDurationMs": 5, + "messages": []map[string]any{{"role": "user", "content": "ping"}}, }, http.StatusAccepted, &taskResponse) if taskResponse.Task.ID == "" || taskResponse.Task.Status != "succeeded" || taskResponse.Task.RunMode != "simulation" { t.Fatalf("unexpected task response: %+v", taskResponse.Task) @@ -271,10 +272,11 @@ VALUES ($1, 5, '{"purpose":"core-flow"}'::jsonb)`, inviteCode); err != nil { var compatChat map[string]any doJSON(t, server.URL, http.MethodPost, "/v1/chat/completions", apiKeyResponse.Secret, map[string]any{ - "model": "gpt-4o-mini", - "runMode": "simulation", - "messages": []map[string]any{{"role": "user", "content": "ping"}}, - "simulation": true, + "model": "gpt-4o-mini", + "runMode": "simulation", + "messages": []map[string]any{{"role": "user", "content": "ping"}}, + "simulation": true, + "simulationDurationMs": 5, }, http.StatusOK, &compatChat) if compatChat["object"] != "chat.completion" { t.Fatalf("unexpected compatible chat response: %+v", compatChat) @@ -288,12 +290,13 @@ VALUES ($1, 5, '{"purpose":"core-flow"}'::jsonb)`, inviteCode); err != nil { } `json:"task"` } doJSON(t, server.URL, http.MethodPost, "/api/v1/images/generations", apiKeyResponse.Secret, map[string]any{ - "model": "gpt-image-1", - "runMode": "simulation", - "prompt": "a tiny gateway console", - "size": "1024x1024", - "quality": "medium", - "simulation": true, + "model": "gpt-image-1", + "runMode": "simulation", + "prompt": "a tiny gateway console", + "size": "1024x1024", + "quality": "medium", + "simulation": true, + "simulationDurationMs": 5, }, http.StatusAccepted, &imageResponse) if imageResponse.Task.Status != "succeeded" || imageResponse.Task.Result["id"] == "" { t.Fatalf("unexpected image generation task: %+v", imageResponse.Task) @@ -307,12 +310,13 @@ VALUES ($1, 5, '{"purpose":"core-flow"}'::jsonb)`, inviteCode); err != nil { } `json:"task"` } doJSON(t, server.URL, http.MethodPost, "/api/v1/images/edits", apiKeyResponse.Secret, map[string]any{ - "model": "gpt-image-1", - "runMode": "simulation", - "prompt": "replace background with clean studio light", - "image": "https://example.com/source.png", - "mask": "https://example.com/mask.png", - "simulation": true, + "model": "gpt-image-1", + "runMode": "simulation", + "prompt": "replace background with clean studio light", + "image": "https://example.com/source.png", + "mask": "https://example.com/mask.png", + "simulation": true, + "simulationDurationMs": 5, }, http.StatusAccepted, &imageEditResponse) if imageEditResponse.Task.Status != "succeeded" || imageEditResponse.Task.Result["id"] == "" { t.Fatalf("unexpected image edit task: %+v", imageEditResponse.Task) diff --git a/apps/api/internal/httpapi/server.go b/apps/api/internal/httpapi/server.go index 6875514..07d5803 100644 --- a/apps/api/internal/httpapi/server.go +++ b/apps/api/internal/httpapi/server.go @@ -32,6 +32,7 @@ func NewServer(cfg config.Config, db *store.Store, logger *slog.Logger) http.Han mux := http.NewServeMux() mux.HandleFunc("GET /healthz", server.health) mux.HandleFunc("GET /readyz", server.ready) + mux.HandleFunc("GET /static/simulation/{asset}", serveSimulationAsset) mux.Handle("POST /api/v1/auth/register", server.auth.Require(auth.PermissionPublic, http.HandlerFunc(server.register))) mux.Handle("POST /api/v1/auth/login", server.auth.Require(auth.PermissionPublic, http.HandlerFunc(server.login))) diff --git a/apps/api/internal/httpapi/simulation_assets.go b/apps/api/internal/httpapi/simulation_assets.go new file mode 100644 index 0000000..fb70a6f --- /dev/null +++ b/apps/api/internal/httpapi/simulation_assets.go @@ -0,0 +1,49 @@ +package httpapi + +import ( + "bytes" + "encoding/base64" + "net/http" + "strings" + "time" +) + +const simulationImageSVG = `EasyAI Demo` + +const simulationImageEditSVG = `EasyAI Edit Demo` + +const simulationVideoPosterSVG = `EasyAI Video Demo` + +const simulationVideoMP4Base64 = "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" + +var simulationVideoMP4 = mustDecodeSimulationAsset(simulationVideoMP4Base64) + +func serveSimulationAsset(w http.ResponseWriter, r *http.Request) { + asset := strings.ToLower(strings.TrimSpace(r.PathValue("asset"))) + switch asset { + case "image.svg", "image.png": + serveSimulationContent(w, r, "image.svg", "image/svg+xml; charset=utf-8", []byte(simulationImageSVG)) + case "image-edit.svg", "image-edit.png": + serveSimulationContent(w, r, "image-edit.svg", "image/svg+xml; charset=utf-8", []byte(simulationImageEditSVG)) + case "video-poster.svg": + serveSimulationContent(w, r, "video-poster.svg", "image/svg+xml; charset=utf-8", []byte(simulationVideoPosterSVG)) + case "video.mp4": + serveSimulationContent(w, r, "video.mp4", "video/mp4", simulationVideoMP4) + default: + http.NotFound(w, r) + } +} + +func serveSimulationContent(w http.ResponseWriter, r *http.Request, name string, contentType string, payload []byte) { + w.Header().Set("Cache-Control", "public, max-age=3600") + w.Header().Set("Content-Type", contentType) + http.ServeContent(w, r, name, time.Time{}, bytes.NewReader(payload)) +} + +func mustDecodeSimulationAsset(value string) []byte { + decoded, err := base64.StdEncoding.DecodeString(value) + if err != nil { + panic(err) + } + return decoded +}