mirror of
https://github.com/router-for-me/CLIProxyAPI.git
synced 2026-06-17 15:23:01 +08:00
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:
@@ -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))
|
||||
|
||||
@@ -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")
|
||||
|
||||
|
||||
@@ -24,6 +24,7 @@ var aiAPIPrefixes = []string{
|
||||
"/v1/videos",
|
||||
"/v1/messages",
|
||||
"/v1/responses",
|
||||
"/openai/v1/videos",
|
||||
"/v1beta/models/",
|
||||
"/backend-api/codex/",
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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")
|
||||
|
||||
|
||||
@@ -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())
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user