- 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 | # 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. | ||
| 134 | video-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: 16 | 138 | # auth-auto-refresh-workers: 16 |
| @@ -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"` |
| @@ -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 = 7 | 37 | maxXAIVideoReferences = 7 |
| 37 | ) | 38 | ) |
| 38 | 39 | ||
| 40 | const defaultVideoAuthBindingTTL = 3 * time.Hour | ||
| 41 | |||
| 42 | var videoAuthBindings = newVideoAuthBindingStore() | ||
| 43 | |||
| 39 | type xaiVideoCreateMetadata struct { | 44 | type xaiVideoCreateMetadata struct { |
| 40 | Model string | 45 | Model string |
| 41 | Prompt string | 46 | UpstreamModel string |
| 42 | Seconds string | 47 | Prompt string |
| 43 | Size string | 48 | Seconds string |
| 44 | CreatedAt int64 | 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 | func videosModelBase(model string) string { | 125 | func videosModelBase(model string) string { |
| @@ -111,11 +189,6 @@ func canonicalXAIVideosModel(model string) string { · 63 unmodified lines | |||
| 111 | } | 189 | } |
| 112 | 190 | ||
| 113 | func responseVideosModel(model string) string { | 191 | func 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 | ||
| 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 | func buildXAIVideosCreateRequest(rawJSON []byte, model string) ([]byte, xaiVideoCreateMetadata, error) { | 290 | func 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, nil | 350 | return req, meta, nil |
| 242 | } | 351 | } |
| @@ -398,7 +507,7 @@ func buildVideosCreateAPIResponseFromXAI(payload []byte, meta xaiVideoCreateMeta · 155 unmodified lines | |||
| 398 | func buildVideosFailedAPIResponse(model string, code string, message string) []byte { | 507 | func 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 = defaultOpenAIVideosModel | 510 | 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 | |||
| 533 | func (h *OpenAIAPIHandler) VideosCreate(c *gin.Context) { | 642 | func (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 | return | 646 | 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 | return | 660 | return |
| 552 | } | 661 | } |
| 553 | 662 | ||
| @@ -586,7 +695,7 @@ func (h *OpenAIAPIHandler) handleXAIVideosNativePost(c *gin.Context) { · 32 unmodified lines | |||
| 586 | return | 695 | return |
| 587 | } | 696 | } |
| 588 | 697 | ||
| 589 | h.collectXAIVideosNative(c, rawJSON, videoModel) | 698 | h.collectXAIVideosNative(c, rawJSON, videoModel, true) |
| 590 | } | 699 | } |
| 591 | 700 | ||
| 592 | func (h *OpenAIAPIHandler) XAIVideosRetrieve(c *gin.Context) { | 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 | 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 | ||
| 612 | func (h *OpenAIAPIHandler) VideosRetrieve(c *gin.Context) { | 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 | 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 | ||
| 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 | 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 | return | 894 | 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 | return | 935 | 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) |
| @@ -1,13 +1,21 @@ | |||
| 1 | package openai | 1 | package openai |
| 2 | 2 | ||
| 3 | import ( | 3 | import ( |
| 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 resp | 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 | func TestVideosModelValidationAllowsXAIVideoModel(t *testing.T) { | 150 | func 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 | ||
| 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 | func TestVideosCreateFormRequest(t *testing.T) { | 600 | func 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 { |