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 = ``
+
+const simulationImageEditSVG = ``
+
+const simulationVideoPosterSVG = ``
+
+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
+}