diff --git a/backend/internal/service/account.go b/backend/internal/service/account.go index 9413043e23..1ab2e21fdf 100644 --- a/backend/internal/service/account.go +++ b/backend/internal/service/account.go @@ -1251,6 +1251,9 @@ func (a *Account) GetOpenAIRefreshToken() string { return a.GetCredential("refresh_token") } +// GetGrokBaseURL selects the upstream used by Grok text and Responses traffic. +// Grok media traffic has a different transport contract and must use +// GetGrokMediaBaseURL instead. func (a *Account) GetGrokBaseURL() string { if !a.IsGrok() { return "" @@ -1271,12 +1274,45 @@ func (a *Account) GetGrokBaseURL() string { return xai.DefaultBaseURL } +// GetGrokMediaBaseURL selects the upstream used by Grok Imagine APIs. +// +// OAuth text requests need the CLI subscription proxy, but that proxy has a +// smaller request-body limit than the official Imagine API. Media requests can +// contain large base64 inputs, so default OAuth accounts must use api.x.ai. +// API-key accounts and explicit unsafe development overrides retain their +// configured base URL. +func (a *Account) GetGrokMediaBaseURL() string { + if !a.IsGrok() { + return "" + } + if !a.IsGrokOAuth() { + return a.GetGrokBaseURL() + } + + baseURL := a.GetCredential("base_url") + if strings.TrimSpace(baseURL) == "" || isOfficialGrokAPIBaseURL(baseURL) || isOfficialGrokCLIBaseURL(baseURL) { + return xai.DefaultBaseURL + } + if _, err := xai.ValidateTrustedBaseURL(baseURL); err == nil { + return baseURL + } + return xai.DefaultBaseURL +} + func isOfficialGrokAPIBaseURL(raw string) bool { + return isOfficialGrokBaseURL(raw, xai.DefaultBaseURL) +} + +func isOfficialGrokCLIBaseURL(raw string) bool { + return isOfficialGrokBaseURL(raw, xai.DefaultCLIBaseURL) +} + +func isOfficialGrokBaseURL(raw, expected string) bool { parsed, err := url.Parse(strings.TrimSpace(raw)) if err != nil || parsed == nil || parsed.Opaque != "" || parsed.User != nil || parsed.RawQuery != "" || parsed.Fragment != "" { return false } - defaultURL, err := url.Parse(xai.DefaultBaseURL) + defaultURL, err := url.Parse(expected) if err != nil { return false } diff --git a/backend/internal/service/account_base_url_test.go b/backend/internal/service/account_base_url_test.go index e86d6c8c1a..59f53db3db 100644 --- a/backend/internal/service/account_base_url_test.go +++ b/backend/internal/service/account_base_url_test.go @@ -306,3 +306,104 @@ func TestGetGrokBaseURLAllowsExplicitOAuthOverrideWhenUnsafeOverridesEnabled(t * require.Equal(t, "https://custom.example.com/v1", account.GetGrokBaseURL()) } + +func TestGetGrokMediaBaseURLSeparatesOAuthMediaFromCLIProxy(t *testing.T) { + tests := []struct { + name string + account Account + expected string + }{ + { + name: "oauth without base_url uses official media API", + account: Account{ + Type: AccountTypeOAuth, + Platform: PlatformGrok, + Credentials: map[string]any{}, + }, + expected: xai.DefaultBaseURL, + }, + { + name: "oauth stored CLI proxy uses official media API", + account: Account{ + Type: AccountTypeOAuth, + Platform: PlatformGrok, + Credentials: map[string]any{ + "base_url": xai.DefaultCLIBaseURL, + }, + }, + expected: xai.DefaultBaseURL, + }, + { + name: "oauth stored CLI proxy variant uses official media API", + account: Account{ + Type: AccountTypeOAuth, + Platform: PlatformGrok, + Credentials: map[string]any{ + "base_url": "HTTPS://CLI-CHAT-PROXY.GROK.COM:443/%76%31/", + }, + }, + expected: xai.DefaultBaseURL, + }, + { + name: "oauth legacy official API remains on official media API", + account: Account{ + Type: AccountTypeOAuth, + Platform: PlatformGrok, + Credentials: map[string]any{ + "base_url": xai.DefaultBaseURL, + }, + }, + expected: xai.DefaultBaseURL, + }, + { + name: "oauth untrusted custom base_url is pinned to official media API", + account: Account{ + Type: AccountTypeOAuth, + Platform: PlatformGrok, + Credentials: map[string]any{ + "base_url": "https://custom.example.com/v1", + }, + }, + expected: xai.DefaultBaseURL, + }, + { + name: "API key retains its configured media API", + account: Account{ + Type: AccountTypeAPIKey, + Platform: PlatformGrok, + Credentials: map[string]any{ + "base_url": "https://grok.example.com/v1", + }, + }, + expected: "https://grok.example.com/v1", + }, + { + name: "non-Grok account has no Grok media base URL", + account: Account{ + Type: AccountTypeOAuth, + Platform: PlatformOpenAI, + Credentials: map[string]any{}, + }, + expected: "", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + require.Equal(t, tt.expected, tt.account.GetGrokMediaBaseURL()) + }) + } +} + +func TestGetGrokMediaBaseURLAllowsExplicitOAuthOverrideWhenUnsafeOverridesEnabled(t *testing.T) { + t.Setenv(xai.EnvAllowUnsafeURLOverrides, "true") + account := Account{ + Type: AccountTypeOAuth, + Platform: PlatformGrok, + Credentials: map[string]any{ + "base_url": "https://custom.example.com/v1", + }, + } + + require.Equal(t, "https://custom.example.com/v1", account.GetGrokMediaBaseURL()) +} diff --git a/backend/internal/service/grok_media.go b/backend/internal/service/grok_media.go index 46faf61a6b..100d720659 100644 --- a/backend/internal/service/grok_media.go +++ b/backend/internal/service/grok_media.go @@ -308,7 +308,7 @@ func (s *OpenAIGatewayService) ForwardGrokMedia( if err != nil { return nil, err } - targetURL, err := endpoint.upstreamURL(account.GetGrokBaseURL(), requestID) + targetURL, err := endpoint.upstreamURL(account.GetGrokMediaBaseURL(), requestID) if err != nil { return nil, err } diff --git a/backend/internal/service/openai_gateway_grok_test.go b/backend/internal/service/openai_gateway_grok_test.go index f0067e99a2..a3edfacf9d 100644 --- a/backend/internal/service/openai_gateway_grok_test.go +++ b/backend/internal/service/openai_gateway_grok_test.go @@ -611,6 +611,42 @@ func TestForwardGrokMediaVideoGenerationPreservesImageToVideoModel(t *testing.T) require.Equal(t, VideoBillingDefaultDurationSeconds, result.VideoDurationSeconds) } +func TestForwardGrokMediaOAuthImageToVideoUsesOfficialAPIForLargeBody(t *testing.T) { + gin.SetMode(gin.TestMode) + + recorder := httptest.NewRecorder() + c, _ := gin.CreateTestContext(recorder) + imageData := strings.Repeat("A", 2*1024*1024) + body := []byte(`{"model":"grok-imagine-video-1.5","prompt":"animate","image":{"image_url":"data:image/png;base64,` + imageData + `"}}`) + c.Request = httptest.NewRequest(http.MethodPost, "/v1/videos/generations", bytes.NewReader(body)) + c.Request.Header.Set("Content-Type", "application/json") + + account := &Account{ + ID: 66, + Name: "grok-oauth", + Platform: PlatformGrok, + Type: AccountTypeOAuth, + Concurrency: 1, + Credentials: map[string]any{ + "access_token": "oauth-access-token", + "base_url": xai.DefaultCLIBaseURL, + }, + } + upstream := &httpUpstreamRecorder{resp: &http.Response{ + StatusCode: http.StatusOK, + Header: http.Header{ + "Content-Type": []string{"application/json"}, + }, + Body: io.NopCloser(strings.NewReader(`{"request_id":"video-request-oauth"}`)), + }} + svc := &OpenAIGatewayService{httpUpstream: upstream} + + _, err := svc.ForwardGrokMedia(context.Background(), c, account, GrokMediaEndpointVideosGenerations, "", body, "application/json") + require.NoError(t, err) + require.Equal(t, xai.DefaultBaseURL+"/videos/generations", upstream.lastReq.URL.String()) + require.Equal(t, "data:image/png;base64,"+imageData, gjson.GetBytes(upstream.lastBody, "image.image_url").String()) +} + func TestForwardGrokMediaVideoStatusUsesGETWithoutBody(t *testing.T) { t.Setenv(xai.EnvAllowUnsafeURLOverrides, "true") gin.SetMode(gin.TestMode)