From 7de9757c82ddce433fef2a986b9764bcbf8f18d0 Mon Sep 17 00:00:00 2001 From: Luis Pater Date: Mon, 15 Jun 2026 01:53:52 +0800 Subject: [PATCH] feat: add OpenAI video support with improved error handling and response normalization - Introduced `/openai/v1/videos` endpoint to support OpenAI-specific video generation. - Added error normalization and handling for OpenAI video resources, including detailed error propagation. - Enhanced response structure to include OpenAI-specific fields for status, progress, and model mappings. - Implemented new handlers for video content retrieval and error scenarios. - Expanded test coverage to validate OpenAI video support, error handling, and backend compatibility. --- internal/api/server.go | 10 +- internal/api/server_test.go | 39 +++ internal/logging/gin_logger.go | 1 + internal/logging/gin_logger_test.go | 6 + .../handlers/openai/openai_videos_handlers.go | 298 +++++++++++++++--- .../openai/openai_videos_handlers_test.go | 202 ++++++++++-- 6 files changed, 492 insertions(+), 64 deletions(-) diff --git a/internal/api/server.go b/internal/api/server.go index 67d4bd687..4572d3c16 100644 --- a/internal/api/server.go +++ b/internal/api/server.go @@ -434,7 +434,7 @@ func (s *Server) setupRoutes() { v1.POST("/completions", openaiHandlers.Completions) v1.POST("/images/generations", openaiHandlers.ImagesGenerations) v1.POST("/images/edits", openaiHandlers.ImagesEdits) - v1.POST("/videos", openaiHandlers.VideosCreate) + v1.POST("/videos", openaiHandlers.XAIVideosGenerations) v1.POST("/videos/generations", openaiHandlers.XAIVideosGenerations) v1.POST("/videos/edits", openaiHandlers.XAIVideosEdits) v1.POST("/videos/extensions", openaiHandlers.XAIVideosExtensions) @@ -446,6 +446,14 @@ func (s *Server) setupRoutes() { v1.POST("/responses/compact", openaiResponsesHandlers.Compact) } + openaiV1 := s.engine.Group("/openai/v1") + openaiV1.Use(AuthMiddleware(s.accessManager)) + { + openaiV1.POST("/videos", openaiHandlers.VideosCreate) + openaiV1.GET("/videos/:video_id/content", openaiHandlers.VideosContent) + openaiV1.GET("/videos/:video_id", openaiHandlers.VideosRetrieve) + } + // Codex CLI direct route aliases (chatgpt_base_url compatible) codexDirect := s.engine.Group("/backend-api/codex") codexDirect.Use(AuthMiddleware(s.accessManager)) diff --git a/internal/api/server_test.go b/internal/api/server_test.go index 901faa3d8..0f42cac19 100644 --- a/internal/api/server_test.go +++ b/internal/api/server_test.go @@ -267,6 +267,45 @@ func TestManagementPluginsRouteRegistered(t *testing.T) { } } +func TestVideosRoutesKeepXAINativeAndExposeOpenAIPrefix(t *testing.T) { + server := newTestServer(t) + + nativeReq := httptest.NewRequest(http.MethodPost, "/v1/videos", strings.NewReader(`{"model":"sora-2","prompt":"make a video"}`)) + nativeReq.Header.Set("Authorization", "Bearer test-key") + nativeReq.Header.Set("Content-Type", "application/json") + nativeRR := httptest.NewRecorder() + server.engine.ServeHTTP(nativeRR, nativeReq) + if nativeRR.Code != http.StatusBadRequest { + t.Fatalf("native status = %d, want %d body=%s", nativeRR.Code, http.StatusBadRequest, nativeRR.Body.String()) + } + if !strings.Contains(nativeRR.Body.String(), "/v1/videos/generations") { + t.Fatalf("expected /v1/videos to keep xAI native validation, body=%s", nativeRR.Body.String()) + } + + openAIReq := httptest.NewRequest(http.MethodPost, "/openai/v1/videos", strings.NewReader(`{"model":`)) + openAIReq.Header.Set("Authorization", "Bearer test-key") + openAIReq.Header.Set("Content-Type", "application/json") + openAIRR := httptest.NewRecorder() + server.engine.ServeHTTP(openAIRR, openAIReq) + if openAIRR.Code != http.StatusBadRequest { + t.Fatalf("openai create status = %d, want %d body=%s", openAIRR.Code, http.StatusBadRequest, openAIRR.Body.String()) + } + if !strings.Contains(openAIRR.Body.String(), "body must be valid JSON") { + t.Fatalf("expected /openai/v1/videos create handler, body=%s", openAIRR.Body.String()) + } + + contentReq := httptest.NewRequest(http.MethodGet, "/openai/v1/videos/video_123/content?variant=thumbnail", nil) + contentReq.Header.Set("Authorization", "Bearer test-key") + contentRR := httptest.NewRecorder() + server.engine.ServeHTTP(contentRR, contentReq) + if contentRR.Code != http.StatusBadRequest { + t.Fatalf("content status = %d, want %d body=%s", contentRR.Code, http.StatusBadRequest, contentRR.Body.String()) + } + if !strings.Contains(contentRR.Body.String(), "variant") { + t.Fatalf("expected /openai/v1/videos content handler, body=%s", contentRR.Body.String()) + } +} + func TestHomeEnabledHidesManagementEndpointsAndControlPanel(t *testing.T) { t.Setenv("MANAGEMENT_PASSWORD", "test-management-key") diff --git a/internal/logging/gin_logger.go b/internal/logging/gin_logger.go index a4c9aa085..446c97fb0 100644 --- a/internal/logging/gin_logger.go +++ b/internal/logging/gin_logger.go @@ -24,6 +24,7 @@ var aiAPIPrefixes = []string{ "/v1/videos", "/v1/messages", "/v1/responses", + "/openai/v1/videos", "/v1beta/models/", "/backend-api/codex/", } diff --git a/internal/logging/gin_logger_test.go b/internal/logging/gin_logger_test.go index b8ae2c9bd..a3c203aef 100644 --- a/internal/logging/gin_logger_test.go +++ b/internal/logging/gin_logger_test.go @@ -72,6 +72,12 @@ func TestIsAIAPIPathIncludesImages(t *testing.T) { if !isAIAPIPath("/v1/videos/video_123") { t.Fatalf("expected /v1/videos/video_123 to be treated as AI API path") } + if !isAIAPIPath("/openai/v1/videos") { + t.Fatalf("expected /openai/v1/videos to be treated as AI API path") + } + if !isAIAPIPath("/openai/v1/videos/video_123/content") { + t.Fatalf("expected /openai/v1/videos/video_123/content to be treated as AI API path") + } } func TestIsAIAPIPathIncludesCodexBackend(t *testing.T) { diff --git a/sdk/api/handlers/openai/openai_videos_handlers.go b/sdk/api/handlers/openai/openai_videos_handlers.go index 2319c1e86..35857cc2a 100644 --- a/sdk/api/handlers/openai/openai_videos_handlers.go +++ b/sdk/api/handlers/openai/openai_videos_handlers.go @@ -4,30 +4,36 @@ import ( "context" "encoding/json" "fmt" + "io" "net/http" + "net/url" "strconv" "strings" "time" "github.com/gin-gonic/gin" + "github.com/google/uuid" "github.com/router-for-me/CLIProxyAPI/v7/internal/interfaces" "github.com/router-for-me/CLIProxyAPI/v7/sdk/api/handlers" + log "github.com/sirupsen/logrus" "github.com/tidwall/gjson" "github.com/tidwall/sjson" ) const ( - videosPath = "/v1/videos" - xaiVideosGenerationsAPI = "/v1/videos/generations" - xaiVideosEditsAPI = "/v1/videos/edits" - xaiVideosExtensionsAPI = "/v1/videos/extensions" - defaultXAIVideosModel = "grok-imagine-video" - xaiVideos15PreviewModel = "grok-imagine-video-1.5-preview" - xaiVideosHandlerType = "openai-video" - defaultVideosSeconds = "4" - defaultVideosSize = "720x1280" - defaultVideosResolution = "720p" - maxXAIVideoReferences = 7 + videosPath = "/v1/videos" + openAIVideosPath = "/openai/v1/videos" + xaiVideosGenerationsAPI = "/v1/videos/generations" + xaiVideosEditsAPI = "/v1/videos/edits" + xaiVideosExtensionsAPI = "/v1/videos/extensions" + defaultOpenAIVideosModel = "sora-2" + defaultXAIVideosModel = "grok-imagine-video" + xaiVideos15PreviewModel = "grok-imagine-video-1.5-preview" + xaiVideosHandlerType = "openai-video" + defaultVideosSeconds = "4" + defaultVideosSize = "720x1280" + defaultVideosResolution = "720p" + maxXAIVideoReferences = 7 ) type xaiVideoCreateMetadata struct { @@ -54,8 +60,14 @@ func isXAIVideosModel(model string) bool { return prefix == "" || prefix == "xai" || prefix == "x-ai" || prefix == "grok" } +func isSoraVideosModel(model string) bool { + _, baseModel := imagesModelParts(model) + baseModel = strings.ToLower(strings.TrimSpace(baseModel)) + return baseModel == defaultOpenAIVideosModel || strings.HasPrefix(baseModel, defaultOpenAIVideosModel+"-") +} + func isSupportedVideosModel(model string) bool { - return isXAIVideosModel(model) + return isXAIVideosModel(model) || isSoraVideosModel(model) } func rejectUnsupportedVideosModel(c *gin.Context, model string) bool { @@ -63,17 +75,16 @@ func rejectUnsupportedVideosModel(c *gin.Context, model string) bool { return false } - c.JSON(http.StatusBadRequest, handlers.ErrorResponse{ - Error: handlers.ErrorDetail{ - Message: fmt.Sprintf("Model %s is not supported on %s. Use %s.", model, videosPath, defaultXAIVideosModel), - Type: "invalid_request_error", - }, - }) + path := strings.TrimSpace(c.Request.URL.Path) + if path == "" { + path = openAIVideosPath + } + writeVideosFailedError(c, http.StatusBadRequest, model, "invalid_request_error", fmt.Sprintf("Model %s is not supported on %s. Use %s.", model, path, defaultOpenAIVideosModel)) return true } func rejectUnsupportedNativeVideosModel(c *gin.Context, model string) bool { - if isSupportedVideosModel(model) { + if isXAIVideosModel(model) { return false } @@ -87,6 +98,9 @@ func rejectUnsupportedNativeVideosModel(c *gin.Context, model string) bool { } func canonicalXAIVideosModel(model string) string { + if isSoraVideosModel(model) { + return defaultXAIVideosModel + } switch videosModelBase(model) { case defaultXAIVideosModel: return defaultXAIVideosModel @@ -96,6 +110,15 @@ func canonicalXAIVideosModel(model string) string { return defaultXAIVideosModel } +func responseVideosModel(model string) string { + _, baseModel := imagesModelParts(model) + baseModel = strings.TrimSpace(baseModel) + if isSoraVideosModel(baseModel) { + return baseModel + } + return canonicalXAIVideosModel(model) +} + func readVideosCreateRequest(c *gin.Context) ([]byte, error) { contentType := strings.ToLower(strings.TrimSpace(c.ContentType())) switch contentType { @@ -209,7 +232,7 @@ func buildXAIVideosCreateRequest(rawJSON []byte, model string) ([]byte, xaiVideo } meta := xaiVideoCreateMetadata{ - Model: videoModel, + Model: responseVideosModel(model), Prompt: prompt, Seconds: seconds, Size: size, @@ -372,37 +395,126 @@ func buildVideosCreateAPIResponseFromXAI(payload []byte, meta xaiVideoCreateMeta return out, nil } +func buildVideosFailedAPIResponse(model string, code string, message string) []byte { + model = strings.TrimSpace(model) + if model == "" { + model = defaultOpenAIVideosModel + } + code = strings.TrimSpace(code) + if code == "" { + code = "invalid_request_error" + } + message = strings.TrimSpace(message) + if message == "" { + message = "Video generation failed" + } + + out := []byte(`{"object":"video","status":"failed","progress":0}`) + out, _ = sjson.SetBytes(out, "id", "video_"+strings.ReplaceAll(uuid.NewString(), "-", "")) + out, _ = sjson.SetBytes(out, "model", model) + out, _ = sjson.SetBytes(out, "error.code", code) + out, _ = sjson.SetBytes(out, "error.message", message) + return out +} + +func writeVideosFailedError(c *gin.Context, status int, model string, code string, message string) { + if status <= 0 { + status = http.StatusBadRequest + } + c.Data(status, "application/json", buildVideosFailedAPIResponse(model, code, message)) +} + func buildVideosRetrieveAPIResponseFromXAI(videoID string, payload []byte, fallbackModel string) ([]byte, error) { out := []byte(`{"object":"video"}`) out, _ = sjson.SetBytes(out, "id", videoID) - model := strings.TrimSpace(gjson.GetBytes(payload, "model").String()) if model == "" { - model = fallbackModel + model = responseVideosModel(fallbackModel) } out, _ = sjson.SetBytes(out, "model", model) + for _, field := range []string{"created_at", "completed_at", "expires_at", "prompt", "remixed_from_video_id", "size"} { + if value := gjson.GetBytes(payload, field); value.Exists() { + out, _ = sjson.SetRawBytes(out, field, []byte(value.Raw)) + } + } + if status := openAIVideoStatus(gjson.GetBytes(payload, "status").String()); status != "" { out, _ = sjson.SetBytes(out, "status", status) } if progress := gjson.GetBytes(payload, "progress"); progress.Exists() { out, _ = sjson.SetRawBytes(out, "progress", []byte(progress.Raw)) } - if duration := gjson.GetBytes(payload, "video.duration"); duration.Exists() { + if seconds := gjson.GetBytes(payload, "seconds"); seconds.Exists() { + out, _ = sjson.SetRawBytes(out, "seconds", []byte(seconds.Raw)) + } else if duration := gjson.GetBytes(payload, "video.duration"); duration.Exists() { out, _ = sjson.SetBytes(out, "seconds", duration.String()) } - if video := gjson.GetBytes(payload, "video"); video.Exists() && json.Valid([]byte(video.Raw)) { - out, _ = sjson.SetRawBytes(out, "video", []byte(video.Raw)) - } - if usage := gjson.GetBytes(payload, "usage"); usage.Exists() && json.Valid([]byte(usage.Raw)) { - out, _ = sjson.SetRawBytes(out, "usage", []byte(usage.Raw)) - } - if errPayload := gjson.GetBytes(payload, "error"); errPayload.Exists() && json.Valid([]byte(errPayload.Raw)) { - out, _ = sjson.SetRawBytes(out, "error", []byte(errPayload.Raw)) - } + out = setOpenAIVideoErrorFromXAI(out, payload) return out, nil } +func setOpenAIVideoErrorFromXAI(out []byte, payload []byte) []byte { + if errPayload := gjson.GetBytes(payload, "error"); errPayload.Exists() { + out = markOpenAIVideoFailed(out) + if errPayload.Type == gjson.JSON && json.Valid([]byte(errPayload.Raw)) { + message := strings.TrimSpace(errPayload.Get("message").String()) + if message != "" { + code := strings.TrimSpace(gjson.GetBytes(payload, "code").String()) + if code == "" { + code = strings.TrimSpace(errPayload.Get("code").String()) + } + if code == "" { + code = "video_generation_failed" + } + out, _ = sjson.SetBytes(out, "error.code", code) + out, _ = sjson.SetBytes(out, "error.message", message) + } + return out + } + message := strings.TrimSpace(errPayload.String()) + if message != "" { + code := strings.TrimSpace(gjson.GetBytes(payload, "code").String()) + if code == "" { + code = "video_generation_failed" + } + out, _ = sjson.SetBytes(out, "error.code", code) + out, _ = sjson.SetBytes(out, "error.message", message) + } + return out + } + + code := strings.TrimSpace(gjson.GetBytes(payload, "code").String()) + if code != "" { + out = markOpenAIVideoFailed(out) + out, _ = sjson.SetBytes(out, "error.code", code) + out, _ = sjson.SetBytes(out, "error.message", code) + } + return out +} + +func markOpenAIVideoFailed(out []byte) []byte { + if !gjson.GetBytes(out, "status").Exists() { + out, _ = sjson.SetBytes(out, "status", "failed") + } + if !gjson.GetBytes(out, "progress").Exists() { + out, _ = sjson.SetRawBytes(out, "progress", []byte("0")) + } + return out +} + +func xaiVideoContentURLFromPayload(payload []byte) (string, error) { + rawURL := strings.TrimSpace(gjson.GetBytes(payload, "video.url").String()) + if rawURL == "" { + return "", fmt.Errorf("xAI video response did not include video.url") + } + parsed, err := url.Parse(rawURL) + if err != nil || parsed == nil || (parsed.Scheme != "http" && parsed.Scheme != "https") || parsed.Host == "" { + return "", fmt.Errorf("xAI video response included invalid video.url") + } + return rawURL, nil +} + func openAIVideoStatus(status string) string { switch strings.ToLower(strings.TrimSpace(status)) { case "queued", "pending": @@ -421,12 +533,7 @@ func openAIVideoStatus(status string) string { func (h *OpenAIAPIHandler) VideosCreate(c *gin.Context) { rawJSON, err := readVideosCreateRequest(c) if err != nil { - c.JSON(http.StatusBadRequest, handlers.ErrorResponse{ - Error: handlers.ErrorDetail{ - Message: fmt.Sprintf("Invalid request: %v", err), - Type: "invalid_request_error", - }, - }) + writeVideosFailedError(c, http.StatusBadRequest, defaultOpenAIVideosModel, "invalid_request_error", fmt.Sprintf("Invalid request: %v", err)) return } @@ -440,12 +547,7 @@ func (h *OpenAIAPIHandler) VideosCreate(c *gin.Context) { xaiReq, meta, err := buildXAIVideosCreateRequest(rawJSON, videoModel) if err != nil { - c.JSON(http.StatusBadRequest, handlers.ErrorResponse{ - Error: handlers.ErrorDetail{ - Message: fmt.Sprintf("Invalid request: %v", err), - Type: "invalid_request_error", - }, - }) + writeVideosFailedError(c, http.StatusBadRequest, videoModel, "invalid_request_error", fmt.Sprintf("Invalid request: %v", err)) return } @@ -537,7 +639,7 @@ func (h *OpenAIAPIHandler) VideosRetrieve(c *gin.Context) { return } - out, err := buildVideosRetrieveAPIResponseFromXAI(videoID, resp, defaultXAIVideosModel) + out, err := buildVideosRetrieveAPIResponseFromXAI(videoID, resp, defaultOpenAIVideosModel) if err != nil { errMsg := &interfaces.ErrorMessage{StatusCode: http.StatusBadGateway, Error: err} h.WriteErrorResponse(c, errMsg) @@ -550,6 +652,112 @@ func (h *OpenAIAPIHandler) VideosRetrieve(c *gin.Context) { cliCancel(nil) } +func (h *OpenAIAPIHandler) VideosContent(c *gin.Context) { + videoID := strings.TrimSpace(c.Param("video_id")) + if videoID == "" { + c.JSON(http.StatusBadRequest, handlers.ErrorResponse{ + Error: handlers.ErrorDetail{ + Message: "Invalid request: video_id is required", + Type: "invalid_request_error", + }, + }) + return + } + + variant := strings.TrimSpace(c.Query("variant")) + if variant == "" { + variant = "video" + } + if variant != "video" { + c.JSON(http.StatusBadRequest, handlers.ErrorResponse{ + Error: handlers.ErrorDetail{ + Message: fmt.Sprintf("Invalid request: variant %q is not available for xAI video downloads", variant), + Type: "invalid_request_error", + }, + }) + return + } + + payload := []byte(`{}`) + payload, _ = sjson.SetBytes(payload, "request_id", videoID) + + cliCtx, cliCancel := h.GetContextWithCancel(h, c, context.Background()) + stopKeepAlive := h.StartNonStreamingKeepAlive(c, cliCtx) + resp, _, errMsg := h.ExecuteWithAuthManager(cliCtx, xaiVideosHandlerType, defaultXAIVideosModel, payload, "") + stopKeepAlive() + if errMsg != nil { + h.WriteErrorResponse(c, errMsg) + if errMsg.Error != nil { + cliCancel(errMsg.Error) + } else { + cliCancel(nil) + } + return + } + + contentURL, err := xaiVideoContentURLFromPayload(resp) + if err != nil { + errMsg := &interfaces.ErrorMessage{StatusCode: http.StatusBadGateway, Error: err} + h.WriteErrorResponse(c, errMsg) + cliCancel(err) + return + } + + if errDownload := h.writeVideoContentFromURL(c, contentURL); errDownload != nil { + cliCancel(errDownload) + return + } + cliCancel(nil) +} + +func (h *OpenAIAPIHandler) writeVideoContentFromURL(c *gin.Context, contentURL string) error { + req, err := http.NewRequestWithContext(c.Request.Context(), http.MethodGet, contentURL, nil) + if err != nil { + errMsg := &interfaces.ErrorMessage{StatusCode: http.StatusBadGateway, Error: err} + h.WriteErrorResponse(c, errMsg) + return err + } + + resp, err := http.DefaultClient.Do(req) + if err != nil { + errMsg := &interfaces.ErrorMessage{StatusCode: http.StatusBadGateway, Error: err} + h.WriteErrorResponse(c, errMsg) + return err + } + defer func() { + if errClose := resp.Body.Close(); errClose != nil { + log.Errorf("video content body close error: %v", errClose) + } + }() + + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + body, _ := io.ReadAll(resp.Body) + errDownloadStatus := fmt.Errorf("video content download failed: %s", strings.TrimSpace(string(body))) + if strings.TrimSpace(string(body)) == "" { + errDownloadStatus = fmt.Errorf("video content download failed: %s", resp.Status) + } + errMsg := &interfaces.ErrorMessage{StatusCode: resp.StatusCode, Error: errDownloadStatus} + h.WriteErrorResponse(c, errMsg) + return errDownloadStatus + } + + copyVideoContentHeaders(c.Writer.Header(), resp.Header) + if c.Writer.Header().Get("Content-Type") == "" { + c.Writer.Header().Set("Content-Type", "application/octet-stream") + } + c.Status(resp.StatusCode) + _, err = io.Copy(c.Writer, resp.Body) + return err +} + +func copyVideoContentHeaders(dst http.Header, src http.Header) { + for _, key := range []string{"Content-Type", "Content-Length", "Content-Disposition", "Cache-Control", "ETag", "Last-Modified"} { + if value := src.Get(key); value != "" { + dst.Set(key, value) + } + } +} + func (h *OpenAIAPIHandler) collectXAIVideosNative(c *gin.Context, rawJSON []byte, model string) { c.Header("Content-Type", "application/json") diff --git a/sdk/api/handlers/openai/openai_videos_handlers_test.go b/sdk/api/handlers/openai/openai_videos_handlers_test.go index 5e4568b4c..1465f948a 100644 --- a/sdk/api/handlers/openai/openai_videos_handlers_test.go +++ b/sdk/api/handlers/openai/openai_videos_handlers_test.go @@ -47,8 +47,11 @@ func TestVideosModelValidationAllowsXAIVideoModel(t *testing.T) { t.Fatalf("expected %s to be supported", model) } } - if isSupportedVideosModel("sora-2") { - t.Fatal("expected sora-2 to be rejected") + if !isSupportedVideosModel("sora-2") { + t.Fatal("expected sora-2 to be supported by the OpenAI video wrapper") + } + if isXAIVideosModel("sora-2") { + t.Fatal("expected sora-2 not to be treated as a native xAI video model") } if isSupportedVideosModel("codex/grok-imagine-video") { t.Fatal("expected codex/grok-imagine-video to be rejected") @@ -58,6 +61,22 @@ func TestVideosModelValidationAllowsXAIVideoModel(t *testing.T) { } } +func TestBuildXAIVideosCreateRequestMapsSoraModelToXAIBackend(t *testing.T) { + rawJSON := []byte(`{"model":"sora-2","prompt":"a cat playing piano","seconds":"8"}`) + + req, meta, err := buildXAIVideosCreateRequest(rawJSON, "sora-2") + if err != nil { + t.Fatalf("buildXAIVideosCreateRequest() error = %v", err) + } + + if got := gjson.GetBytes(req, "model").String(); got != defaultXAIVideosModel { + t.Fatalf("upstream model = %q, want %s", got, defaultXAIVideosModel) + } + if meta.Model != "sora-2" { + t.Fatalf("response model = %q, want sora-2", meta.Model) + } +} + func TestBuildXAIVideosCreateRequest(t *testing.T) { rawJSON := []byte(`{"model":"xai/grok-imagine-video","prompt":"a cat playing piano","seconds":"8","size":"1280x720","input_reference":{"image_url":"https://example.com/cat.png"}}`) @@ -158,43 +177,190 @@ func TestBuildVideosCreateAPIResponseFromXAI(t *testing.T) { } func TestBuildVideosRetrieveAPIResponseFromXAI(t *testing.T) { - payload := []byte(`{"status":"done","video":{"url":"https://vidgen.x.ai/video.mp4","duration":6,"respect_moderation":true},"model":"grok-imagine-video","usage":{"cost_in_usd_ticks":500000000},"progress":100}`) + payload := []byte(`{"object":"video","id":"91989464-273f-95df-8197-703b4fefd40e","model":"grok-imagine-video","status":"completed","progress":100,"seconds":"4","video":{"url":"https://vidgen.x.ai/xai-vidgen-bucket/xai-video-08609066-e7e9-43ba-bd8d-bd29cb6221d9.mp4","duration":4,"respect_moderation":true},"usage":{"cost_in_usd_ticks":2800000000}}`) - out, err := buildVideosRetrieveAPIResponseFromXAI("vid_123", payload, defaultXAIVideosModel) + out, err := buildVideosRetrieveAPIResponseFromXAI("91989464-273f-95df-8197-703b4fefd40e", payload, defaultOpenAIVideosModel) if err != nil { t.Fatalf("buildVideosRetrieveAPIResponseFromXAI() error = %v", err) } - if got := gjson.GetBytes(out, "id").String(); got != "vid_123" { - t.Fatalf("id = %q, want vid_123", got) + if got := gjson.GetBytes(out, "id").String(); got != "91989464-273f-95df-8197-703b4fefd40e" { + t.Fatalf("id = %q", got) + } + if got := gjson.GetBytes(out, "object").String(); got != "video" { + t.Fatalf("object = %q, want video", got) + } + if got := gjson.GetBytes(out, "model").String(); got != defaultXAIVideosModel { + t.Fatalf("model = %q, want %s", got, defaultXAIVideosModel) } if got := gjson.GetBytes(out, "status").String(); got != "completed" { t.Fatalf("status = %q, want completed", got) } - if got := gjson.GetBytes(out, "seconds").String(); got != "6" { - t.Fatalf("seconds = %q, want 6", got) + if got := gjson.GetBytes(out, "progress").Int(); got != 100 { + t.Fatalf("progress = %d, want 100", got) } - if got := gjson.GetBytes(out, "video.url").String(); got != "https://vidgen.x.ai/video.mp4" { - t.Fatalf("video.url = %q", got) + if got := gjson.GetBytes(out, "seconds").String(); got != "4" { + t.Fatalf("seconds = %q, want 4", got) } - if !gjson.GetBytes(out, "usage").Exists() { - t.Fatalf("usage missing: %s", string(out)) + if gjson.GetBytes(out, "video").Exists() { + t.Fatalf("video field must not be exposed in OpenAI retrieve response: %s", string(out)) + } + if gjson.GetBytes(out, "usage").Exists() { + t.Fatalf("usage field must not be exposed in OpenAI retrieve response: %s", string(out)) + } +} + +func TestBuildVideosRetrieveAPIResponseFromXAINormalizesTopLevelError(t *testing.T) { + payload := []byte(`{"code":"invalid-argument","error":"1080p video resolution is not available for your team."}`) + + out, err := buildVideosRetrieveAPIResponseFromXAI("video_123", payload, defaultOpenAIVideosModel) + if err != nil { + t.Fatalf("buildVideosRetrieveAPIResponseFromXAI() error = %v", err) + } + + if got := gjson.GetBytes(out, "status").String(); got != "failed" { + t.Fatalf("status = %q, want failed", got) + } + if got := gjson.GetBytes(out, "progress").Int(); got != 0 { + t.Fatalf("progress = %d, want 0", got) + } + if got := gjson.GetBytes(out, "error.code").String(); got != "invalid-argument" { + t.Fatalf("error.code = %q, want invalid-argument", got) + } + if got := gjson.GetBytes(out, "error.message").String(); got != "1080p video resolution is not available for your team." { + t.Fatalf("error.message = %q", got) + } +} + +func TestBuildVideosRetrieveAPIResponseFromXAINormalizesNestedError(t *testing.T) { + payload := []byte(`{"status":"failed","error":{"message":"The request was rejected by the safety system.","type":"invalid_request_error","code":"content_policy_violation"}}`) + + out, err := buildVideosRetrieveAPIResponseFromXAI("video_123", payload, defaultOpenAIVideosModel) + if err != nil { + t.Fatalf("buildVideosRetrieveAPIResponseFromXAI() error = %v", err) + } + + if got := gjson.GetBytes(out, "error.code").String(); got != "content_policy_violation" { + t.Fatalf("error.code = %q, want content_policy_violation", got) + } + if got := gjson.GetBytes(out, "error.message").String(); got != "The request was rejected by the safety system." { + t.Fatalf("error.message = %q", got) + } + if gjson.GetBytes(out, "error.type").Exists() { + t.Fatalf("error.type must not be present: %s", string(out)) + } +} + +func TestXAIVideoContentURLFromPayload(t *testing.T) { + payload := []byte(`{"status":"done","video":{"url":"https://vidgen.x.ai/video.mp4","duration":6}}`) + + got, err := xaiVideoContentURLFromPayload(payload) + if err != nil { + t.Fatalf("xaiVideoContentURLFromPayload() error = %v", err) + } + if got != "https://vidgen.x.ai/video.mp4" { + t.Fatalf("url = %q, want https://vidgen.x.ai/video.mp4", got) + } +} + +func TestWriteVideoContentFromURL(t *testing.T) { + upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "video/mp4") + w.Header().Set("Content-Disposition", `attachment; filename="video.mp4"`) + _, _ = w.Write([]byte("video-bytes")) + })) + defer upstream.Close() + + gin.SetMode(gin.TestMode) + resp := httptest.NewRecorder() + ctx, _ := gin.CreateTestContext(resp) + ctx.Request = httptest.NewRequest(http.MethodGet, "/openai/v1/videos/video_123/content", nil) + + handler := &OpenAIAPIHandler{} + if err := handler.writeVideoContentFromURL(ctx, upstream.URL+"/video.mp4"); err != nil { + t.Fatalf("writeVideoContentFromURL() error = %v", err) + } + + if resp.Code != http.StatusOK { + t.Fatalf("status = %d, want %d body=%s", resp.Code, http.StatusOK, resp.Body.String()) + } + if got := resp.Header().Get("Content-Type"); got != "video/mp4" { + t.Fatalf("Content-Type = %q, want video/mp4", got) + } + if got := resp.Header().Get("Content-Disposition"); got != `attachment; filename="video.mp4"` { + t.Fatalf("Content-Disposition = %q", got) + } + if got := resp.Body.String(); got != "video-bytes" { + t.Fatalf("body = %q, want video-bytes", got) } } func TestVideosCreateRejectsUnsupportedModel(t *testing.T) { handler := &OpenAIAPIHandler{} - body := strings.NewReader(`{"model":"sora-2","prompt":"make a video"}`) + body := strings.NewReader(`{"model":"not-a-video-model","prompt":"make a video"}`) - resp := performVideosEndpointRequest(t, http.MethodPost, videosPath, "application/json", body, handler.VideosCreate) + resp := performVideosEndpointRequest(t, http.MethodPost, openAIVideosPath, "application/json", body, handler.VideosCreate) if resp.Code != http.StatusBadRequest { t.Fatalf("status = %d, want %d: %s", resp.Code, http.StatusBadRequest, resp.Body.String()) } - message := gjson.GetBytes(resp.Body.Bytes(), "error.message").String() - expectedMessage := "Model sora-2 is not supported on " + videosPath + ". Use " + defaultXAIVideosModel + "." - if message != expectedMessage { - t.Fatalf("error message = %q, want %q", message, expectedMessage) + if got := gjson.GetBytes(resp.Body.Bytes(), "object").String(); got != "video" { + t.Fatalf("object = %q, want video", got) + } + if got := gjson.GetBytes(resp.Body.Bytes(), "model").String(); got != "not-a-video-model" { + t.Fatalf("model = %q, want not-a-video-model", got) + } + if got := gjson.GetBytes(resp.Body.Bytes(), "status").String(); got != "failed" { + t.Fatalf("status = %q, want failed", got) + } + if got := gjson.GetBytes(resp.Body.Bytes(), "progress").Int(); got != 0 { + t.Fatalf("progress = %d, want 0", got) + } + if got := gjson.GetBytes(resp.Body.Bytes(), "error.code").String(); got != "invalid_request_error" { + t.Fatalf("error.code = %q, want invalid_request_error", got) + } + expectedMessage := "Model not-a-video-model is not supported on " + openAIVideosPath + ". Use " + defaultOpenAIVideosModel + "." + if got := gjson.GetBytes(resp.Body.Bytes(), "error.message").String(); got != expectedMessage { + t.Fatalf("error.message = %q, want %q", got, expectedMessage) + } + if gjson.GetBytes(resp.Body.Bytes(), "error.type").Exists() { + t.Fatalf("error.type must not be present: %s", resp.Body.String()) + } + if id := gjson.GetBytes(resp.Body.Bytes(), "id").String(); !strings.HasPrefix(id, "video_") { + t.Fatalf("id = %q, want video_ prefix", id) + } +} + +func TestVideosCreateInvalidSizeReturnsFailedVideoResource(t *testing.T) { + handler := &OpenAIAPIHandler{} + body := strings.NewReader(`{"model":"sora-2","prompt":"make a video","size":"1080x1920"}`) + + resp := performVideosEndpointRequest(t, http.MethodPost, openAIVideosPath, "application/json", body, handler.VideosCreate) + + if resp.Code != http.StatusBadRequest { + t.Fatalf("status = %d, want %d: %s", resp.Code, http.StatusBadRequest, resp.Body.String()) + } + if got := gjson.GetBytes(resp.Body.Bytes(), "object").String(); got != "video" { + t.Fatalf("object = %q, want video", got) + } + if got := gjson.GetBytes(resp.Body.Bytes(), "model").String(); got != "sora-2" { + t.Fatalf("model = %q, want sora-2", got) + } + if got := gjson.GetBytes(resp.Body.Bytes(), "status").String(); got != "failed" { + t.Fatalf("status = %q, want failed", got) + } + if got := gjson.GetBytes(resp.Body.Bytes(), "progress").Int(); got != 0 { + t.Fatalf("progress = %d, want 0", got) + } + if got := gjson.GetBytes(resp.Body.Bytes(), "error.code").String(); got != "invalid_request_error" { + t.Fatalf("error.code = %q, want invalid_request_error", got) + } + expectedMessage := "Invalid request: size must be one of 720x1280, 1280x720, 1024x1792, or 1792x1024" + if got := gjson.GetBytes(resp.Body.Bytes(), "error.message").String(); got != expectedMessage { + t.Fatalf("error.message = %q, want %q", got, expectedMessage) + } + if gjson.GetBytes(resp.Body.Bytes(), "error.type").Exists() { + t.Fatalf("error.type must not be present: %s", resp.Body.String()) } }