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.
This commit is contained in:
Luis Pater
2026-06-15 01:53:52 +08:00
parent ea4e978c60
commit 7de9757c82
6 changed files with 492 additions and 64 deletions

View File

@@ -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))

View File

@@ -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")

View File

@@ -24,6 +24,7 @@ var aiAPIPrefixes = []string{
"/v1/videos",
"/v1/messages",
"/v1/responses",
"/openai/v1/videos",
"/v1beta/models/",
"/backend-api/codex/",
}

View File

@@ -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) {

View File

@@ -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")

View File

@@ -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())
}
}