- 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.
Unified · Split
| @@ -129,6 +129,10 @@ disable-image-generation: false · 128 unmodified lines | ||
| 129 | 129 | # Must start with "gpt-" (case-insensitive). If unset or invalid, defaults to "gpt-5.4-mini". |
| 130 | 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. | |
| 134 | +video-result-auth-cache-ttl: "3h" | |
| 135 | + | |
| 132 | 136 | # Core auth auto-refresh worker pool size (OAuth/file-based auth token refresh). |
| 133 | 137 | # When > 0, overrides the default worker count (16). |
| 134 | 138 | # auth-auto-refresh-workers: 16 |
| @@ -28,6 +28,11 @@ type SDKConfig struct { · 27 unmodified lines | ||
| 28 | 28 | // default base model ("gpt-5.4-mini") is used. |
| 29 | 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 | 36 | // EnableGeminiCLIEndpoint controls whether Gemini CLI internal endpoints (/v1internal:*) are enabled. |
| 32 | 37 | // Default is false for safety; when false, /v1internal:* requests are rejected. |
| 33 | 38 | EnableGeminiCLIEndpoint bool `yaml:"enable-gemini-cli-endpoint" json:"enable-gemini-cli-endpoint"` |
| @@ -9,6 +9,7 @@ import ( · 8 unmodified lines | ||
| 9 | 9 | "net/url" |
| 10 | 10 | "strconv" |
| 11 | 11 | "strings" |
| 12 | + "sync" | |
| 12 | 13 | "time" |
| 13 | 14 | |
| 14 | 15 | "github.com/gin-gonic/gin" |
| @@ -36,12 +37,89 @@ const ( · 21 unmodified lines | ||
| 36 | 37 | maxXAIVideoReferences = 7 |
| 37 | 38 | ) |
| 38 | 39 | |
| 40 | +const defaultVideoAuthBindingTTL = 3 * time.Hour | |
| 41 | + | |
| 42 | +var videoAuthBindings = newVideoAuthBindingStore() | |
| 43 | + | |
| 39 | 44 | type xaiVideoCreateMetadata struct { |
| 40 | − Model string | |
| 41 | − Prompt string | |
| 42 | − Seconds string | |
| 43 | − Size string | |
| 44 | − CreatedAt int64 | |
| 45 | + Model string | |
| 46 | + UpstreamModel string | |
| 47 | + Prompt string | |
| 48 | + Seconds string | |
| 49 | + Size string | |
| 50 | + CreatedAt int64 | |
| 51 | +} | |
| 52 | + | |
| 53 | +type videoAuthBinding struct { | |
| 54 | + authID string | |
| 55 | + expiresAt time.Time | |
| 56 | +} | |
| 57 | + | |
| 58 | +type videoAuthBindingStore struct { | |
| 59 | + mu sync.RWMutex | |
| 60 | + entries map[string]videoAuthBinding | |
| 61 | +} | |
| 62 | + | |
| 63 | +func newVideoAuthBindingStore() *videoAuthBindingStore { | |
| 64 | + return &videoAuthBindingStore{ | |
| 65 | + entries: make(map[string]videoAuthBinding), | |
| 66 | + } | |
| 67 | +} | |
| 68 | + | |
| 69 | +func (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 | + | |
| 91 | +func (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 | + | |
| 117 | +func (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 | |
| 47 | 125 | func videosModelBase(model string) string { |
| @@ -111,11 +189,6 @@ func canonicalXAIVideosModel(model string) string { · 63 unmodified lines | ||
| 111 | 189 | } |
| 112 | 190 | |
| 113 | 191 | func responseVideosModel(model string) string { |
| 114 | − _, baseModel := imagesModelParts(model) | |
| 115 | − baseModel = strings.TrimSpace(baseModel) | |
| 116 | − if isSoraVideosModel(baseModel) { | |
| 117 | − return baseModel | |
| 118 | − } | |
| 119 | 192 | return canonicalXAIVideosModel(model) |
| 120 | 193 | } |
| 121 | 194 | |
| @@ -179,6 +252,41 @@ func firstPostForm(c *gin.Context, keys ...string) string { · 57 unmodified lines | ||
| 179 | 252 | return "" |
| 180 | 253 | } |
| 181 | 254 | |
| 255 | +func (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 | + | |
| 267 | +func 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 | + | |
| 275 | +func (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 | + | |
| 283 | +func (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 | + | |
| 182 | 290 | func buildXAIVideosCreateRequest(rawJSON []byte, model string) ([]byte, xaiVideoCreateMetadata, error) { |
| 183 | 291 | prompt := strings.TrimSpace(gjson.GetBytes(rawJSON, "prompt").String()) |
| 184 | 292 | if prompt == "" { |
| @@ -232,11 +340,12 @@ func buildXAIVideosCreateRequest(rawJSON []byte, model string) ([]byte, xaiVideo · 47 unmodified lines | ||
| 232 | 340 | } |
| 233 | 341 | |
| 234 | 342 | meta := xaiVideoCreateMetadata{ |
| 235 | − Model: responseVideosModel(model), | |
| 236 | − Prompt: prompt, | |
| 237 | − Seconds: seconds, | |
| 238 | − Size: size, | |
| 239 | − CreatedAt: time.Now().Unix(), | |
| 343 | + Model: responseVideosModel(model), | |
| 344 | + UpstreamModel: videoModel, | |
| 345 | + Prompt: prompt, | |
| 346 | + Seconds: seconds, | |
| 347 | + Size: size, | |
| 348 | + CreatedAt: time.Now().Unix(), | |
| 240 | 349 | } |
| 241 | 350 | return req, meta, nil |
| 242 | 351 | } |
| @@ -398,7 +507,7 @@ func buildVideosCreateAPIResponseFromXAI(payload []byte, meta xaiVideoCreateMeta · 155 unmodified lines | ||
| 398 | 507 | func buildVideosFailedAPIResponse(model string, code string, message string) []byte { |
| 399 | 508 | model = strings.TrimSpace(model) |
| 400 | 509 | if model == "" { |
| 401 | − model = defaultOpenAIVideosModel | |
| 510 | + model = defaultXAIVideosModel | |
| 402 | 511 | } |
| 403 | 512 | code = strings.TrimSpace(code) |
| 404 | 513 | if code == "" { |
| @@ -533,7 +642,7 @@ func openAIVideoStatus(status string) string { · 128 unmodified lines | ||
| 533 | 642 | func (h *OpenAIAPIHandler) VideosCreate(c *gin.Context) { |
| 534 | 643 | rawJSON, err := readVideosCreateRequest(c) |
| 535 | 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 | 646 | return |
| 538 | 647 | } |
| 539 | 648 | |
| @@ -547,7 +656,7 @@ func (h *OpenAIAPIHandler) VideosCreate(c *gin.Context) { · 7 unmodified lines | ||
| 547 | 656 | |
| 548 | 657 | xaiReq, meta, err := buildXAIVideosCreateRequest(rawJSON, videoModel) |
| 549 | 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 | 660 | return |
| 552 | 661 | } |
| 553 | 662 | |
| @@ -586,7 +695,7 @@ func (h *OpenAIAPIHandler) handleXAIVideosNativePost(c *gin.Context) { · 32 unmodified lines | ||
| 586 | 695 | return |
| 587 | 696 | } |
| 588 | 697 | |
| 589 | − h.collectXAIVideosNative(c, rawJSON, videoModel) | |
| 698 | + h.collectXAIVideosNative(c, rawJSON, videoModel, true) | |
| 590 | 699 | } |
| 591 | 700 | |
| 592 | 701 | func (h *OpenAIAPIHandler) XAIVideosRetrieve(c *gin.Context) { |
| @@ -606,7 +715,7 @@ func (h *OpenAIAPIHandler) XAIVideosRetrieve(c *gin.Context) { · 13 unmodified lines | ||
| 606 | 715 | |
| 607 | 716 | payload := []byte(`{}`) |
| 608 | 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 | |
| 612 | 721 | func (h *OpenAIAPIHandler) VideosRetrieve(c *gin.Context) { |
| @@ -626,6 +735,7 @@ func (h *OpenAIAPIHandler) VideosRetrieve(c *gin.Context) { · 13 unmodified lines | ||
| 626 | 735 | |
| 627 | 736 | c.Header("Content-Type", "application/json") |
| 628 | 737 | cliCtx, cliCancel := h.GetContextWithCancel(h, c, context.Background()) |
| 738 | + cliCtx = h.contextWithVideoAuthBinding(cliCtx, videoID) | |
| 629 | 739 | stopKeepAlive := h.StartNonStreamingKeepAlive(c, cliCtx) |
| 630 | 740 | resp, upstreamHeaders, errMsg := h.ExecuteWithAuthManager(cliCtx, xaiVideosHandlerType, defaultXAIVideosModel, payload, "") |
| 631 | 741 | stopKeepAlive() |
| @@ -682,6 +792,7 @@ func (h *OpenAIAPIHandler) VideosContent(c *gin.Context) { · 50 unmodified lines | ||
| 682 | 792 | payload, _ = sjson.SetBytes(payload, "request_id", videoID) |
| 683 | 793 | |
| 684 | 794 | cliCtx, cliCancel := h.GetContextWithCancel(h, c, context.Background()) |
| 795 | + cliCtx = h.contextWithVideoAuthBinding(cliCtx, videoID) | |
| 685 | 796 | stopKeepAlive := h.StartNonStreamingKeepAlive(c, cliCtx) |
| 686 | 797 | resp, _, errMsg := h.ExecuteWithAuthManager(cliCtx, xaiVideosHandlerType, defaultXAIVideosModel, payload, "") |
| 687 | 798 | stopKeepAlive() |
| @@ -758,10 +869,18 @@ func copyVideoContentHeaders(dst http.Header, src http.Header) { · 70 unmodified lines | ||
| 758 | 869 | } |
| 759 | 870 | } |
| 760 | 871 | |
| 761 | −func (h *OpenAIAPIHandler) collectXAIVideosNative(c *gin.Context, rawJSON []byte, model string) { | |
| 872 | +func (h *OpenAIAPIHandler) collectXAIVideosNative(c *gin.Context, rawJSON []byte, model string, bindCreatedVideoAuth bool) { | |
| 762 | 873 | c.Header("Content-Type", "application/json") |
| 763 | 874 | |
| 764 | 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 | 884 | stopKeepAlive := h.StartNonStreamingKeepAlive(c, cliCtx) |
| 766 | 885 | resp, upstreamHeaders, errMsg := h.ExecuteWithAuthManager(cliCtx, xaiVideosHandlerType, model, rawJSON, "") |
| 767 | 886 | stopKeepAlive() |
| @@ -775,6 +894,9 @@ func (h *OpenAIAPIHandler) collectXAIVideosNative(c *gin.Context, rawJSON []byte · 7 unmodified lines | ||
| 775 | 894 | return |
| 776 | 895 | } |
| 777 | 896 | |
| 897 | + if bindCreatedVideoAuth { | |
| 898 | + h.bindVideoAuthIDFromPayload(resp, selectedAuthID) | |
| 899 | + } | |
| 778 | 900 | handlers.WriteUpstreamHeaders(c.Writer.Header(), upstreamHeaders) |
| 779 | 901 | _, _ = c.Writer.Write(resp) |
| 780 | 902 | cliCancel(nil) |
| @@ -784,8 +906,16 @@ func (h *OpenAIAPIHandler) collectXAIVideosCreate(c *gin.Context, xaiReq []byte, · 3 unmodified lines | ||
| 784 | 906 | c.Header("Content-Type", "application/json") |
| 785 | 907 | |
| 786 | 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 | 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 | 919 | stopKeepAlive() |
| 790 | 920 | if errMsg != nil { |
| 791 | 921 | h.WriteErrorResponse(c, errMsg) |
| @@ -805,6 +935,7 @@ func (h *OpenAIAPIHandler) collectXAIVideosCreate(c *gin.Context, xaiReq []byte, · 13 unmodified lines | ||
| 805 | 935 | return |
| 806 | 936 | } |
| 807 | 937 | |
| 938 | + h.bindVideoAuthIDFromPayload(out, selectedAuthID) | |
| 808 | 939 | handlers.WriteUpstreamHeaders(c.Writer.Header(), upstreamHeaders) |
| 809 | 940 | _, _ = c.Writer.Write(out) |
| 810 | 941 | cliCancel(nil) |
| @@ -1,13 +1,21 @@ | ||
| 1 | 1 | package openai |
| 2 | 2 | |
| 3 | 3 | import ( |
| 4 | + "context" | |
| 4 | 5 | "io" |
| 5 | 6 | "net/http" |
| 6 | 7 | "net/http/httptest" |
| 7 | 8 | "strings" |
| 9 | + "sync" | |
| 8 | 10 | "testing" |
| 11 | + "time" | |
| 9 | 12 | |
| 10 | 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 | 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 | 40 | return resp |
| 33 | 41 | } |
| 34 | 42 | |
| 43 | +func 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 | + | |
| 64 | +type videoAuthCaptureExecutor struct { | |
| 65 | + mu sync.Mutex | |
| 66 | + requestID string | |
| 67 | + authIDs []string | |
| 68 | +} | |
| 69 | + | |
| 70 | +func (e *videoAuthCaptureExecutor) Identifier() string { return "xai" } | |
| 71 | + | |
| 72 | +func (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 | + | |
| 89 | +func (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 | + | |
| 93 | +func (e *videoAuthCaptureExecutor) Refresh(_ context.Context, auth *coreauth.Auth) (*coreauth.Auth, error) { | |
| 94 | + return auth, nil | |
| 95 | +} | |
| 96 | + | |
| 97 | +func (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 | + | |
| 101 | +func (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 | + | |
| 105 | +func (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 | + | |
| 113 | +func resetVideoAuthBindingsForTest(t *testing.T) { | |
| 114 | + t.Helper() | |
| 115 | + previous := videoAuthBindings | |
| 116 | + videoAuthBindings = newVideoAuthBindingStore() | |
| 117 | + t.Cleanup(func() { | |
| 118 | + videoAuthBindings = previous | |
| 119 | + }) | |
| 120 | +} | |
| 121 | + | |
| 122 | +func 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 | + | |
| 35 | 150 | func TestVideosModelValidationAllowsXAIVideoModel(t *testing.T) { |
| 36 | 151 | for _, model := range []string{ |
| 37 | 152 | "grok-imagine-video", |
| @@ -72,8 +187,8 @@ func TestBuildXAIVideosCreateRequestMapsSoraModelToXAIBackend(t *testing.T) { · 34 unmodified lines | ||
| 72 | 187 | if got := gjson.GetBytes(req, "model").String(); got != defaultXAIVideosModel { |
| 73 | 188 | t.Fatalf("upstream model = %q, want %s", got, defaultXAIVideosModel) |
| 74 | 189 | } |
| 75 | − if meta.Model != "sora-2" { | |
| 76 | − t.Fatalf("response model = %q, want sora-2", meta.Model) | |
| 190 | + if meta.Model != defaultXAIVideosModel { | |
| 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 | 458 | if got := gjson.GetBytes(resp.Body.Bytes(), "object").String(); got != "video" { |
| 344 | 459 | t.Fatalf("object = %q, want video", got) |
| 345 | 460 | } |
| 346 | − if got := gjson.GetBytes(resp.Body.Bytes(), "model").String(); got != "sora-2" { | |
| 347 | − t.Fatalf("model = %q, want sora-2", got) | |
| 461 | + if got := gjson.GetBytes(resp.Body.Bytes(), "model").String(); got != defaultXAIVideosModel { | |
| 462 | + t.Fatalf("model = %q, want %s", got, defaultXAIVideosModel) | |
| 348 | 463 | } |
| 349 | 464 | if got := gjson.GetBytes(resp.Body.Bytes(), "status").String(); got != "failed" { |
| 350 | 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 | |
| 512 | +func 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 | + | |
| 543 | +func 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 | + | |
| 571 | +func 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 | + | |
| 585 | +func 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 | + | |
| 397 | 600 | func TestVideosCreateFormRequest(t *testing.T) { |
| 398 | 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 | 602 | if err != nil { |