History

feat(videos): add video authentication binding and update handler behavior

- Introduced `videoAuthBindingStore` for managing mappings of video IDs to credentials with TTL support. - Updated video creation and retrieval handlers to bind and utilize credentials for authentication. - Enhanced response models to include upstream models and adjusted request preparation logic. - Added test coverage for video auth binding, TTL configuration, and expiration handling.

Luis Pater committed 5 months ago

Commit bbef8da1 parent f85768e4 files · +369 −26Browse files

Unified · Split

4 files
  1. config.example.yaml +4 −0
  2. internal/config/sdk_config.go +5 −0
  3. sdk/api/handlers/openai/openai_videos_handlers.go +153 −22
  4. sdk/api/handlers/openai/openai_videos_handlers_test.go +207 −4
config.example.yaml+4−0
@@ -129,6 +129,10 @@ disable-image-generation: false · 128 unmodified lines
129# Must start with "gpt-" (case-insensitive). If unset or invalid, defaults to "gpt-5.4-mini".129# Must start with "gpt-" (case-insensitive). If unset or invalid, defaults to "gpt-5.4-mini".
130# gpt-image-2-base-model: "gpt-5.4-mini"130# gpt-image-2-base-model: "gpt-5.4-mini"
131 131
132# How long video IDs returned by /openai/v1/videos and xAI video creation stay bound
133# to the credential that created them. Default: 3h.
134video-result-auth-cache-ttl: "3h"
135
132# Core auth auto-refresh worker pool size (OAuth/file-based auth token refresh).136# Core auth auto-refresh worker pool size (OAuth/file-based auth token refresh).
133# When > 0, overrides the default worker count (16).137# When > 0, overrides the default worker count (16).
134# auth-auto-refresh-workers: 16138# auth-auto-refresh-workers: 16
internal/config/sdk_config.go+5−0
@@ -28,6 +28,11 @@ type SDKConfig struct { · 27 unmodified lines
28 // default base model ("gpt-5.4-mini") is used.28 // default base model ("gpt-5.4-mini") is used.
29 GPTImage2BaseModel string `yaml:"gpt-image-2-base-model,omitempty" json:"gpt-image-2-base-model,omitempty"`29 GPTImage2BaseModel string `yaml:"gpt-image-2-base-model,omitempty" json:"gpt-image-2-base-model,omitempty"`
30 30
31 // VideoResultAuthCacheTTL controls how long video IDs stay pinned to the credential
32 // that created them. Accepts duration strings like "30m" or "3h".
33 // Empty or invalid values use the default 3h.
34 VideoResultAuthCacheTTL string `yaml:"video-result-auth-cache-ttl,omitempty" json:"video-result-auth-cache-ttl,omitempty"`
35
31 // EnableGeminiCLIEndpoint controls whether Gemini CLI internal endpoints (/v1internal:*) are enabled.36 // EnableGeminiCLIEndpoint controls whether Gemini CLI internal endpoints (/v1internal:*) are enabled.
32 // Default is false for safety; when false, /v1internal:* requests are rejected.37 // Default is false for safety; when false, /v1internal:* requests are rejected.
33 EnableGeminiCLIEndpoint bool `yaml:"enable-gemini-cli-endpoint" json:"enable-gemini-cli-endpoint"`38 EnableGeminiCLIEndpoint bool `yaml:"enable-gemini-cli-endpoint" json:"enable-gemini-cli-endpoint"`
sdk/api/handlers/openai/openai_videos_handlers.go+153−22
@@ -9,6 +9,7 @@ import ( · 8 unmodified lines
9 "net/url"9 "net/url"
10 "strconv"10 "strconv"
11 "strings"11 "strings"
12 "sync"
12 "time"13 "time"
13 14
14 "github.com/gin-gonic/gin"15 "github.com/gin-gonic/gin"
@@ -36,12 +37,89 @@ const ( · 21 unmodified lines
36 maxXAIVideoReferences = 737 maxXAIVideoReferences = 7
37)38)
38 39
40const defaultVideoAuthBindingTTL = 3 * time.Hour
41
42var videoAuthBindings = newVideoAuthBindingStore()
43
39type xaiVideoCreateMetadata struct {44type xaiVideoCreateMetadata struct {
40 Model string45 Model string
41 Prompt string46 UpstreamModel string
42 Seconds string47 Prompt string
43 Size string48 Seconds string
44 CreatedAt int6449 Size string
50 CreatedAt int64
51}
52
53type videoAuthBinding struct {
54 authID string
55 expiresAt time.Time
56}
57
58type videoAuthBindingStore struct {
59 mu sync.RWMutex
60 entries map[string]videoAuthBinding
61}
62
63func newVideoAuthBindingStore() *videoAuthBindingStore {
64 return &videoAuthBindingStore{
65 entries: make(map[string]videoAuthBinding),
66 }
67}
68
69func (s *videoAuthBindingStore) set(videoID string, authID string, ttl time.Duration) {
70 if s == nil {
71 return
72 }
73 videoID = strings.TrimSpace(videoID)
74 authID = strings.TrimSpace(authID)
75 if videoID == "" || authID == "" {
76 return
77 }
78 if ttl <= 0 {
79 ttl = defaultVideoAuthBindingTTL
80 }
81 now := time.Now()
82 s.mu.Lock()
83 s.cleanupExpiredLocked(now)
84 s.entries[videoID] = videoAuthBinding{
85 authID: authID,
86 expiresAt: now.Add(ttl),
87 }
88 s.mu.Unlock()
89}
90
91func (s *videoAuthBindingStore) get(videoID string) (string, bool) {
92 if s == nil {
93 return "", false
94 }
95 videoID = strings.TrimSpace(videoID)
96 if videoID == "" {
97 return "", false
98 }
99 now := time.Now()
100 s.mu.RLock()
101 entry, ok := s.entries[videoID]
102 s.mu.RUnlock()
103 if !ok {
104 return "", false
105 }
106 if now.After(entry.expiresAt) {
107 s.mu.Lock()
108 if current, exists := s.entries[videoID]; exists && now.After(current.expiresAt) {
109 delete(s.entries, videoID)
110 }
111 s.mu.Unlock()
112 return "", false
113 }
114 return entry.authID, true
115}
116
117func (s *videoAuthBindingStore) cleanupExpiredLocked(now time.Time) {
118 for videoID, entry := range s.entries {
119 if now.After(entry.expiresAt) {
120 delete(s.entries, videoID)
121 }
122 }
45}123}
46 124
47func videosModelBase(model string) string {125func videosModelBase(model string) string {
@@ -111,11 +189,6 @@ func canonicalXAIVideosModel(model string) string { · 63 unmodified lines
111}189}
112 190
113func responseVideosModel(model string) string {191func responseVideosModel(model string) string {
114 _, baseModel := imagesModelParts(model)
115 baseModel = strings.TrimSpace(baseModel)
116 if isSoraVideosModel(baseModel) {
117 return baseModel
118 }
119 return canonicalXAIVideosModel(model)192 return canonicalXAIVideosModel(model)
120}193}
121 194
@@ -179,6 +252,41 @@ func firstPostForm(c *gin.Context, keys ...string) string { · 57 unmodified lines
179 return ""252 return ""
180}253}
181 254
255func (h *OpenAIAPIHandler) videoAuthBindingTTL() time.Duration {
256 if h != nil && h.BaseAPIHandler != nil && h.Cfg != nil {
257 raw := strings.TrimSpace(h.Cfg.VideoResultAuthCacheTTL)
258 if raw != "" {
259 if ttl, err := time.ParseDuration(raw); err == nil && ttl > 0 {
260 return ttl
261 }
262 }
263 }
264 return defaultVideoAuthBindingTTL
265}
266
267func videoIDFromPayload(payload []byte) string {
268 videoID := strings.TrimSpace(gjson.GetBytes(payload, "request_id").String())
269 if videoID == "" {
270 videoID = strings.TrimSpace(gjson.GetBytes(payload, "id").String())
271 }
272 return videoID
273}
274
275func (h *OpenAIAPIHandler) bindVideoAuthIDFromPayload(payload []byte, authID string) {
276 videoID := videoIDFromPayload(payload)
277 if videoID == "" {
278 return
279 }
280 videoAuthBindings.set(videoID, authID, h.videoAuthBindingTTL())
281}
282
283func (h *OpenAIAPIHandler) contextWithVideoAuthBinding(ctx context.Context, videoID string) context.Context {
284 if authID, ok := videoAuthBindings.get(videoID); ok {
285 return handlers.WithPinnedAuthID(ctx, authID)
286 }
287 return ctx
288}
289
182func buildXAIVideosCreateRequest(rawJSON []byte, model string) ([]byte, xaiVideoCreateMetadata, error) {290func buildXAIVideosCreateRequest(rawJSON []byte, model string) ([]byte, xaiVideoCreateMetadata, error) {
183 prompt := strings.TrimSpace(gjson.GetBytes(rawJSON, "prompt").String())291 prompt := strings.TrimSpace(gjson.GetBytes(rawJSON, "prompt").String())
184 if prompt == "" {292 if prompt == "" {
@@ -232,11 +340,12 @@ func buildXAIVideosCreateRequest(rawJSON []byte, model string) ([]byte, xaiVideo · 47 unmodified lines
232 }340 }
233 341
234 meta := xaiVideoCreateMetadata{342 meta := xaiVideoCreateMetadata{
235 Model: responseVideosModel(model),343 Model: responseVideosModel(model),
236 Prompt: prompt,344 UpstreamModel: videoModel,
237 Seconds: seconds,345 Prompt: prompt,
238 Size: size,346 Seconds: seconds,
239 CreatedAt: time.Now().Unix(),347 Size: size,
348 CreatedAt: time.Now().Unix(),
240 }349 }
241 return req, meta, nil350 return req, meta, nil
242}351}
@@ -398,7 +507,7 @@ func buildVideosCreateAPIResponseFromXAI(payload []byte, meta xaiVideoCreateMeta · 155 unmodified lines
398func buildVideosFailedAPIResponse(model string, code string, message string) []byte {507func buildVideosFailedAPIResponse(model string, code string, message string) []byte {
399 model = strings.TrimSpace(model)508 model = strings.TrimSpace(model)
400 if model == "" {509 if model == "" {
401 model = defaultOpenAIVideosModel510 model = defaultXAIVideosModel
402 }511 }
403 code = strings.TrimSpace(code)512 code = strings.TrimSpace(code)
404 if code == "" {513 if code == "" {
@@ -533,7 +642,7 @@ func openAIVideoStatus(status string) string { · 128 unmodified lines
533func (h *OpenAIAPIHandler) VideosCreate(c *gin.Context) {642func (h *OpenAIAPIHandler) VideosCreate(c *gin.Context) {
534 rawJSON, err := readVideosCreateRequest(c)643 rawJSON, err := readVideosCreateRequest(c)
535 if err != nil {644 if err != nil {
536 writeVideosFailedError(c, http.StatusBadRequest, defaultOpenAIVideosModel, "invalid_request_error", fmt.Sprintf("Invalid request: %v", err))645 writeVideosFailedError(c, http.StatusBadRequest, defaultXAIVideosModel, "invalid_request_error", fmt.Sprintf("Invalid request: %v", err))
537 return646 return
538 }647 }
539 648
@@ -547,7 +656,7 @@ func (h *OpenAIAPIHandler) VideosCreate(c *gin.Context) { · 7 unmodified lines
547 656
548 xaiReq, meta, err := buildXAIVideosCreateRequest(rawJSON, videoModel)657 xaiReq, meta, err := buildXAIVideosCreateRequest(rawJSON, videoModel)
549 if err != nil {658 if err != nil {
550 writeVideosFailedError(c, http.StatusBadRequest, videoModel, "invalid_request_error", fmt.Sprintf("Invalid request: %v", err))659 writeVideosFailedError(c, http.StatusBadRequest, responseVideosModel(videoModel), "invalid_request_error", fmt.Sprintf("Invalid request: %v", err))
551 return660 return
552 }661 }
553 662
@@ -586,7 +695,7 @@ func (h *OpenAIAPIHandler) handleXAIVideosNativePost(c *gin.Context) { · 32 unmodified lines
586 return695 return
587 }696 }
588 697
589 h.collectXAIVideosNative(c, rawJSON, videoModel)698 h.collectXAIVideosNative(c, rawJSON, videoModel, true)
590}699}
591 700
592func (h *OpenAIAPIHandler) XAIVideosRetrieve(c *gin.Context) {701func (h *OpenAIAPIHandler) XAIVideosRetrieve(c *gin.Context) {
@@ -606,7 +715,7 @@ func (h *OpenAIAPIHandler) XAIVideosRetrieve(c *gin.Context) { · 13 unmodified lines
606 715
607 payload := []byte(`{}`)716 payload := []byte(`{}`)
608 payload, _ = sjson.SetBytes(payload, "request_id", requestID)717 payload, _ = sjson.SetBytes(payload, "request_id", requestID)
609 h.collectXAIVideosNative(c, payload, defaultXAIVideosModel)718 h.collectXAIVideosNative(c, payload, defaultXAIVideosModel, false)
610}719}
611 720
612func (h *OpenAIAPIHandler) VideosRetrieve(c *gin.Context) {721func (h *OpenAIAPIHandler) VideosRetrieve(c *gin.Context) {
@@ -626,6 +735,7 @@ func (h *OpenAIAPIHandler) VideosRetrieve(c *gin.Context) { · 13 unmodified lines
626 735
627 c.Header("Content-Type", "application/json")736 c.Header("Content-Type", "application/json")
628 cliCtx, cliCancel := h.GetContextWithCancel(h, c, context.Background())737 cliCtx, cliCancel := h.GetContextWithCancel(h, c, context.Background())
738 cliCtx = h.contextWithVideoAuthBinding(cliCtx, videoID)
629 stopKeepAlive := h.StartNonStreamingKeepAlive(c, cliCtx)739 stopKeepAlive := h.StartNonStreamingKeepAlive(c, cliCtx)
630 resp, upstreamHeaders, errMsg := h.ExecuteWithAuthManager(cliCtx, xaiVideosHandlerType, defaultXAIVideosModel, payload, "")740 resp, upstreamHeaders, errMsg := h.ExecuteWithAuthManager(cliCtx, xaiVideosHandlerType, defaultXAIVideosModel, payload, "")
631 stopKeepAlive()741 stopKeepAlive()
@@ -682,6 +792,7 @@ func (h *OpenAIAPIHandler) VideosContent(c *gin.Context) { · 50 unmodified lines
682 payload, _ = sjson.SetBytes(payload, "request_id", videoID)792 payload, _ = sjson.SetBytes(payload, "request_id", videoID)
683 793
684 cliCtx, cliCancel := h.GetContextWithCancel(h, c, context.Background())794 cliCtx, cliCancel := h.GetContextWithCancel(h, c, context.Background())
795 cliCtx = h.contextWithVideoAuthBinding(cliCtx, videoID)
685 stopKeepAlive := h.StartNonStreamingKeepAlive(c, cliCtx)796 stopKeepAlive := h.StartNonStreamingKeepAlive(c, cliCtx)
686 resp, _, errMsg := h.ExecuteWithAuthManager(cliCtx, xaiVideosHandlerType, defaultXAIVideosModel, payload, "")797 resp, _, errMsg := h.ExecuteWithAuthManager(cliCtx, xaiVideosHandlerType, defaultXAIVideosModel, payload, "")
687 stopKeepAlive()798 stopKeepAlive()
@@ -758,10 +869,18 @@ func copyVideoContentHeaders(dst http.Header, src http.Header) { · 70 unmodified lines
758 }869 }
759}870}
760 871
761func (h *OpenAIAPIHandler) collectXAIVideosNative(c *gin.Context, rawJSON []byte, model string) {872func (h *OpenAIAPIHandler) collectXAIVideosNative(c *gin.Context, rawJSON []byte, model string, bindCreatedVideoAuth bool) {
762 c.Header("Content-Type", "application/json")873 c.Header("Content-Type", "application/json")
763 874
764 cliCtx, cliCancel := h.GetContextWithCancel(h, c, context.Background())875 cliCtx, cliCancel := h.GetContextWithCancel(h, c, context.Background())
876 selectedAuthID := ""
877 if bindCreatedVideoAuth {
878 cliCtx = handlers.WithSelectedAuthIDCallback(cliCtx, func(authID string) {
879 selectedAuthID = authID
880 })
881 } else {
882 cliCtx = h.contextWithVideoAuthBinding(cliCtx, videoIDFromPayload(rawJSON))
883 }
765 stopKeepAlive := h.StartNonStreamingKeepAlive(c, cliCtx)884 stopKeepAlive := h.StartNonStreamingKeepAlive(c, cliCtx)
766 resp, upstreamHeaders, errMsg := h.ExecuteWithAuthManager(cliCtx, xaiVideosHandlerType, model, rawJSON, "")885 resp, upstreamHeaders, errMsg := h.ExecuteWithAuthManager(cliCtx, xaiVideosHandlerType, model, rawJSON, "")
767 stopKeepAlive()886 stopKeepAlive()
@@ -775,6 +894,9 @@ func (h *OpenAIAPIHandler) collectXAIVideosNative(c *gin.Context, rawJSON []byte · 7 unmodified lines
775 return894 return
776 }895 }
777 896
897 if bindCreatedVideoAuth {
898 h.bindVideoAuthIDFromPayload(resp, selectedAuthID)
899 }
778 handlers.WriteUpstreamHeaders(c.Writer.Header(), upstreamHeaders)900 handlers.WriteUpstreamHeaders(c.Writer.Header(), upstreamHeaders)
779 _, _ = c.Writer.Write(resp)901 _, _ = c.Writer.Write(resp)
780 cliCancel(nil)902 cliCancel(nil)
@@ -784,8 +906,16 @@ func (h *OpenAIAPIHandler) collectXAIVideosCreate(c *gin.Context, xaiReq []byte, · 3 unmodified lines
784 c.Header("Content-Type", "application/json")906 c.Header("Content-Type", "application/json")
785 907
786 cliCtx, cliCancel := h.GetContextWithCancel(h, c, context.Background())908 cliCtx, cliCancel := h.GetContextWithCancel(h, c, context.Background())
909 selectedAuthID := ""
910 cliCtx = handlers.WithSelectedAuthIDCallback(cliCtx, func(authID string) {
911 selectedAuthID = authID
912 })
913 upstreamModel := strings.TrimSpace(meta.UpstreamModel)
914 if upstreamModel == "" {
915 upstreamModel = meta.Model
916 }
787 stopKeepAlive := h.StartNonStreamingKeepAlive(c, cliCtx)917 stopKeepAlive := h.StartNonStreamingKeepAlive(c, cliCtx)
788 resp, upstreamHeaders, errMsg := h.ExecuteWithAuthManager(cliCtx, xaiVideosHandlerType, meta.Model, xaiReq, "")918 resp, upstreamHeaders, errMsg := h.ExecuteWithAuthManager(cliCtx, xaiVideosHandlerType, upstreamModel, xaiReq, "")
789 stopKeepAlive()919 stopKeepAlive()
790 if errMsg != nil {920 if errMsg != nil {
791 h.WriteErrorResponse(c, errMsg)921 h.WriteErrorResponse(c, errMsg)
@@ -805,6 +935,7 @@ func (h *OpenAIAPIHandler) collectXAIVideosCreate(c *gin.Context, xaiReq []byte, · 13 unmodified lines
805 return935 return
806 }936 }
807 937
938 h.bindVideoAuthIDFromPayload(out, selectedAuthID)
808 handlers.WriteUpstreamHeaders(c.Writer.Header(), upstreamHeaders)939 handlers.WriteUpstreamHeaders(c.Writer.Header(), upstreamHeaders)
809 _, _ = c.Writer.Write(out)940 _, _ = c.Writer.Write(out)
810 cliCancel(nil)941 cliCancel(nil)
sdk/api/handlers/openai/openai_videos_handlers_test.go+207−4
@@ -1,13 +1,21 @@
1package openai1package openai
2 2
3import (3import (
4 "context"
4 "io"5 "io"
5 "net/http"6 "net/http"
6 "net/http/httptest"7 "net/http/httptest"
7 "strings"8 "strings"
9 "sync"
8 "testing"10 "testing"
11 "time"
9 12
10 "github.com/gin-gonic/gin"13 "github.com/gin-gonic/gin"
14 "github.com/router-for-me/CLIProxyAPI/v7/internal/registry"
15 apihandlers "github.com/router-for-me/CLIProxyAPI/v7/sdk/api/handlers"
16 coreauth "github.com/router-for-me/CLIProxyAPI/v7/sdk/cliproxy/auth"
17 coreexecutor "github.com/router-for-me/CLIProxyAPI/v7/sdk/cliproxy/executor"
18 sdkconfig "github.com/router-for-me/CLIProxyAPI/v7/sdk/config"
11 "github.com/tidwall/gjson"19 "github.com/tidwall/gjson"
12)20)
13 21
@@ -32,6 +40,113 @@ func performVideosEndpointRequest(t *testing.T, method string, endpointPath stri · 18 unmodified lines
32 return resp40 return resp
33}41}
34 42
43func performVideosRouteRequest(t *testing.T, method string, routePath string, requestPath string, contentType string, body io.Reader, handler gin.HandlerFunc) *httptest.ResponseRecorder {
44 t.Helper()
45
46 gin.SetMode(gin.TestMode)
47 router := gin.New()
48 switch method {
49 case http.MethodGet:
50 router.GET(routePath, handler)
51 default:
52 router.POST(routePath, handler)
53 }
54
55 req := httptest.NewRequest(method, requestPath, body)
56 if contentType != "" {
57 req.Header.Set("Content-Type", contentType)
58 }
59 resp := httptest.NewRecorder()
60 router.ServeHTTP(resp, req)
61 return resp
62}
63
64type videoAuthCaptureExecutor struct {
65 mu sync.Mutex
66 requestID string
67 authIDs []string
68}
69
70func (e *videoAuthCaptureExecutor) Identifier() string { return "xai" }
71
72func (e *videoAuthCaptureExecutor) Execute(_ context.Context, auth *coreauth.Auth, req coreexecutor.Request, _ coreexecutor.Options) (coreexecutor.Response, error) {
73 authID := ""
74 if auth != nil {
75 authID = auth.ID
76 }
77 e.mu.Lock()
78 e.authIDs = append(e.authIDs, authID)
79 e.mu.Unlock()
80
81 requestID := strings.TrimSpace(gjson.GetBytes(req.Payload, "request_id").String())
82 if requestID == "" {
83 requestID = e.requestID
84 }
85 payload := []byte(`{"request_id":"` + requestID + `","status":"completed","progress":100,"video":{"url":"https://vidgen.x.ai/video.mp4","duration":4}}`)
86 return coreexecutor.Response{Payload: payload}, nil
87}
88
89func (e *videoAuthCaptureExecutor) ExecuteStream(context.Context, *coreauth.Auth, coreexecutor.Request, coreexecutor.Options) (*coreexecutor.StreamResult, error) {
90 return nil, &coreauth.Error{Code: "not_implemented", Message: "ExecuteStream not implemented"}
91}
92
93func (e *videoAuthCaptureExecutor) Refresh(_ context.Context, auth *coreauth.Auth) (*coreauth.Auth, error) {
94 return auth, nil
95}
96
97func (e *videoAuthCaptureExecutor) CountTokens(context.Context, *coreauth.Auth, coreexecutor.Request, coreexecutor.Options) (coreexecutor.Response, error) {
98 return coreexecutor.Response{}, &coreauth.Error{Code: "not_implemented", Message: "CountTokens not implemented"}
99}
100
101func (e *videoAuthCaptureExecutor) HttpRequest(context.Context, *coreauth.Auth, *http.Request) (*http.Response, error) {
102 return nil, &coreauth.Error{Code: "not_implemented", Message: "HttpRequest not implemented"}
103}
104
105func (e *videoAuthCaptureExecutor) AuthIDs() []string {
106 e.mu.Lock()
107 defer e.mu.Unlock()
108 out := make([]string, len(e.authIDs))
109 copy(out, e.authIDs)
110 return out
111}
112
113func resetVideoAuthBindingsForTest(t *testing.T) {
114 t.Helper()
115 previous := videoAuthBindings
116 videoAuthBindings = newVideoAuthBindingStore()
117 t.Cleanup(func() {
118 videoAuthBindings = previous
119 })
120}
121
122func newVideoAuthBindingTestHandler(t *testing.T, executor *videoAuthCaptureExecutor) *OpenAIAPIHandler {
123 t.Helper()
124
125 manager := coreauth.NewManager(nil, &coreauth.RoundRobinSelector{}, nil)
126 manager.RegisterExecutor(executor)
127
128 authIDs := []string{executor.requestID + "-auth-a", executor.requestID + "-auth-b"}
129 for _, authID := range authIDs {
130 auth := &coreauth.Auth{
131 ID: authID,
132 Provider: "xai",
133 Status: coreauth.StatusActive,
134 }
135 if _, errRegister := manager.Register(context.Background(), auth); errRegister != nil {
136 t.Fatalf("manager.Register(%s): %v", authID, errRegister)
137 }
138 registry.GetGlobalRegistry().RegisterClient(authID, auth.Provider, []*registry.ModelInfo{{ID: defaultXAIVideosModel}})
139 }
140 t.Cleanup(func() {
141 for _, authID := range authIDs {
142 registry.GetGlobalRegistry().UnregisterClient(authID)
143 }
144 })
145
146 base := apihandlers.NewBaseAPIHandlers(&sdkconfig.SDKConfig{}, manager)
147 return NewOpenAIAPIHandler(base)
148}
149
35func TestVideosModelValidationAllowsXAIVideoModel(t *testing.T) {150func TestVideosModelValidationAllowsXAIVideoModel(t *testing.T) {
36 for _, model := range []string{151 for _, model := range []string{
37 "grok-imagine-video",152 "grok-imagine-video",
@@ -72,8 +187,8 @@ func TestBuildXAIVideosCreateRequestMapsSoraModelToXAIBackend(t *testing.T) { · 34 unmodified lines
72 if got := gjson.GetBytes(req, "model").String(); got != defaultXAIVideosModel {187 if got := gjson.GetBytes(req, "model").String(); got != defaultXAIVideosModel {
73 t.Fatalf("upstream model = %q, want %s", got, defaultXAIVideosModel)188 t.Fatalf("upstream model = %q, want %s", got, defaultXAIVideosModel)
74 }189 }
75 if meta.Model != "sora-2" {190 if meta.Model != defaultXAIVideosModel {
76 t.Fatalf("response model = %q, want sora-2", meta.Model)191 t.Fatalf("response model = %q, want %s", meta.Model, defaultXAIVideosModel)
77 }192 }
78}193}
79 194
@@ -343,8 +458,8 @@ func TestVideosCreateInvalidSizeReturnsFailedVideoResource(t *testing.T) { · 263 unmodified lines
343 if got := gjson.GetBytes(resp.Body.Bytes(), "object").String(); got != "video" {458 if got := gjson.GetBytes(resp.Body.Bytes(), "object").String(); got != "video" {
344 t.Fatalf("object = %q, want video", got)459 t.Fatalf("object = %q, want video", got)
345 }460 }
346 if got := gjson.GetBytes(resp.Body.Bytes(), "model").String(); got != "sora-2" {461 if got := gjson.GetBytes(resp.Body.Bytes(), "model").String(); got != defaultXAIVideosModel {
347 t.Fatalf("model = %q, want sora-2", got)462 t.Fatalf("model = %q, want %s", got, defaultXAIVideosModel)
348 }463 }
349 if got := gjson.GetBytes(resp.Body.Bytes(), "status").String(); got != "failed" {464 if got := gjson.GetBytes(resp.Body.Bytes(), "status").String(); got != "failed" {
350 t.Fatalf("status = %q, want failed", got)465 t.Fatalf("status = %q, want failed", got)
@@ -394,6 +509,94 @@ func TestXAIVideosNativeRejectsInvalidJSON(t *testing.T) { · 43 unmodified lines
394 }509 }
395}510}
396 511
512func TestVideosCreateBindsRetrieveToSelectedAuth(t *testing.T) {
513 resetVideoAuthBindingsForTest(t)
514 executor := &videoAuthCaptureExecutor{requestID: "video-openai-bound"}
515 handler := newVideoAuthBindingTestHandler(t, executor)
516
517 createResp := performVideosEndpointRequest(t, http.MethodPost, openAIVideosPath, "application/json", strings.NewReader(`{"model":"sora-2","prompt":"make a video"}`), handler.VideosCreate)
518 if createResp.Code != http.StatusOK {
519 t.Fatalf("create status = %d, want %d: %s", createResp.Code, http.StatusOK, createResp.Body.String())
520 }
521 videoID := gjson.GetBytes(createResp.Body.Bytes(), "id").String()
522 if videoID != executor.requestID {
523 t.Fatalf("created video id = %q, want %q", videoID, executor.requestID)
524 }
525 if got := gjson.GetBytes(createResp.Body.Bytes(), "model").String(); got != defaultXAIVideosModel {
526 t.Fatalf("created model = %q, want %s", got, defaultXAIVideosModel)
527 }
528
529 retrieveResp := performVideosRouteRequest(t, http.MethodGet, openAIVideosPath+"/:video_id", openAIVideosPath+"/"+videoID, "", nil, handler.VideosRetrieve)
530 if retrieveResp.Code != http.StatusOK {
531 t.Fatalf("retrieve status = %d, want %d: %s", retrieveResp.Code, http.StatusOK, retrieveResp.Body.String())
532 }
533
534 authIDs := executor.AuthIDs()
535 if len(authIDs) != 2 {
536 t.Fatalf("authIDs = %v, want two calls", authIDs)
537 }
538 if authIDs[1] != authIDs[0] {
539 t.Fatalf("retrieve auth = %q, want create auth %q; sequence=%v", authIDs[1], authIDs[0], authIDs)
540 }
541}
542
543func TestXAIVideosNativeCreateBindsRetrieveToSelectedAuth(t *testing.T) {
544 resetVideoAuthBindingsForTest(t)
545 executor := &videoAuthCaptureExecutor{requestID: "video-xai-bound"}
546 handler := newVideoAuthBindingTestHandler(t, executor)
547
548 createResp := performVideosEndpointRequest(t, http.MethodPost, xaiVideosGenerationsAPI, "application/json", strings.NewReader(`{"model":"grok-imagine-video","prompt":"make a video"}`), handler.XAIVideosGenerations)
549 if createResp.Code != http.StatusOK {
550 t.Fatalf("create status = %d, want %d: %s", createResp.Code, http.StatusOK, createResp.Body.String())
551 }
552 videoID := gjson.GetBytes(createResp.Body.Bytes(), "request_id").String()
553 if videoID != executor.requestID {
554 t.Fatalf("created request_id = %q, want %q", videoID, executor.requestID)
555 }
556
557 retrieveResp := performVideosRouteRequest(t, http.MethodGet, videosPath+"/:request_id", videosPath+"/"+videoID, "", nil, handler.XAIVideosRetrieve)
558 if retrieveResp.Code != http.StatusOK {
559 t.Fatalf("retrieve status = %d, want %d: %s", retrieveResp.Code, http.StatusOK, retrieveResp.Body.String())
560 }
561
562 authIDs := executor.AuthIDs()
563 if len(authIDs) != 2 {
564 t.Fatalf("authIDs = %v, want two calls", authIDs)
565 }
566 if authIDs[1] != authIDs[0] {
567 t.Fatalf("retrieve auth = %q, want create auth %q; sequence=%v", authIDs[1], authIDs[0], authIDs)
568 }
569}
570
571func TestVideoAuthBindingTTLUsesConfig(t *testing.T) {
572 base := apihandlers.NewBaseAPIHandlers(&sdkconfig.SDKConfig{VideoResultAuthCacheTTL: "45m"}, nil)
573 handler := NewOpenAIAPIHandler(base)
574 if got := handler.videoAuthBindingTTL(); got != 45*time.Minute {
575 t.Fatalf("videoAuthBindingTTL() = %v, want 45m", got)
576 }
577
578 base = apihandlers.NewBaseAPIHandlers(&sdkconfig.SDKConfig{VideoResultAuthCacheTTL: "invalid"}, nil)
579 handler = NewOpenAIAPIHandler(base)
580 if got := handler.videoAuthBindingTTL(); got != defaultVideoAuthBindingTTL {
581 t.Fatalf("invalid videoAuthBindingTTL() = %v, want %v", got, defaultVideoAuthBindingTTL)
582 }
583}
584
585func TestVideoAuthBindingStoreExpiresEntries(t *testing.T) {
586 store := newVideoAuthBindingStore()
587 store.entries["video-expired"] = videoAuthBinding{
588 authID: "auth-expired",
589 expiresAt: time.Now().Add(-time.Second),
590 }
591
592 if authID, ok := store.get("video-expired"); ok {
593 t.Fatalf("expired binding returned authID=%q", authID)
594 }
595 if _, exists := store.entries["video-expired"]; exists {
596 t.Fatal("expired binding was not removed")
597 }
598}
599
397func TestVideosCreateFormRequest(t *testing.T) {600func TestVideosCreateFormRequest(t *testing.T) {
398 rawJSON, err := videosCreateRequestFromFormContext("model=grok-imagine-video&prompt=make+a+video&seconds=4&size=720x1280&input_reference%5Bimage_url%5D=https%3A%2F%2Fexample.com%2Fa.png")601 rawJSON, err := videosCreateRequestFromFormContext("model=grok-imagine-video&prompt=make+a+video&seconds=4&size=720x1280&input_reference%5Bimage_url%5D=https%3A%2F%2Fexample.com%2Fa.png")
399 if err != nil {602 if err != nil {