Repository: pydantic/pydantic

Issue:
Mypy typing errors with "reusable" `model_validator`
### Initial Checks

- [X] I confirm that I'm using Pydantic V2

### Description

I'm at the final stages of migrating a v1 code base to v2. Functionally it's complete, the code runs as expected without any issues (the v1 to v2 migration guide was _really_ useful). But I'm getting `mypy` errors when using my attempt to migrate "reused" v1 `root_validator` instances to v2 `model_validator` instances.

When I use a decorated `model_validator` inline in the model definition all is well. The model validates as expected and `mypy` is content and happy.

When I use my attempt at a reusable `model_validator` the model still validates as expected but `mypy` is not happy at all.

The "inline" test case I've put together, plus the output from `mypy` is:

```
#!/usr/bin/env python
# pylint: disable=all

from typing import Any, Dict, Optional

from pydantic import BaseModel, ConfigDict, model_validator

def normalise_known_invalids(values: Dict[str, Any]) -> Dict[str, Any]:
    if values:
        for key, value in values.items():
            value = value.strip()
            if value:
                if value.lower() in ['', 'n/a', '-']:
                    values[key] = None
                else:
                    values[key] = value

            else:
                values[key] = None

    return values


class InlineValidatorModel(BaseModel):
    fld: Optional[str] = None

    @model_validator(mode='before')
    @classmethod
    def normaliser(cls, values: Dict[str, Any]) -> Dict[str, Any]:
        return normalise_known_invalids(values)

    model_config = ConfigDict(str_strip_whitespace=True, extra='forbid')

def main():
    model = InlineValidatorModel()
    print(f'empty inline model: {model}')

    model = InlineValidatorModel(fld='OK')
    print(f'valid input inline model: {model}')

    model = InlineValidatorModel(fld='n/a ')
    print(f'invalid input inline model: {model}')

if __name__ == '__main__':
    main()
```

```
$ mypy model_test.py
Success: no issues found in 1 source file
```

But the `mypy` output from the "reused" variant, code in the example below, is:

```
$ mypy model_test2.py
model_test2.py:29: error: Argument 1 has incompatible type "Callable[[dict[str, Any]], dict[str, Any]]"; expected "ModelBeforeValidator | ModelBeforeValidatorWithoutInfo"  [arg-type]
Found 1 error in 1 file (checked 1 source file)
```

Also, this is possibly related to #6693.

### Example Code

```Python
#!/usr/bin/env python
# pylint: disable=all

from typing import Any, Dict, Optional

from pydantic import BaseModel, ConfigDict, model_validator

def normalise_known_invalids(values: Dict[str, Any]) -> Dict[str, Any]:
    if values:
        for key, value in values.items():
            value = value.strip()
            if value:
                if value.lower() in ['', 'n/a', '-']:
                    values[key] = None
                else:
                    values[key] = value

            else:
                values[key] = None

    return values

class ReusedValidatorModel(BaseModel):
    fld: Optional[str] = None

    _normaliser = model_validator(mode='before')(normalise_known_invalids)
    model_config = ConfigDict(str_strip_whitespace=True, extra='forbid')


def main():
    model = ReusedValidatorModel()
    print(f'empty reused model: {model}')

    model = ReusedValidatorModel(fld='OK')
    print(f'valid input reused model: {model}')

    model = ReusedValidatorModel(fld='n/a ')
    print(f'invalid inputreused  model: {model}')

if __name__ == '__main__':
    main()
```


### Python, Pydantic & OS Version

```Text
pydantic version: 2.5.2
        pydantic-core version: 2.14.5
          pydantic-core build: profile=release pgo=true
                 install path: /home/gary/scratch/.direnv/python-3.10.12/lib/python3.10/site-packages/pydantic
               python version: 3.10.12 (main, Nov 20 2023, 15:14:05) [GCC 11.4.0]
                     platform: Linux-6.2.0-37-generic-x86_64-with-glibc2.35
             related packages: mypy-1.7.1 pydantic-settings-2.0.3 typing_extensions-4.8.0
```
```



Discussion:

Resolving patch:
diff --git a/pydantic/functional_validators.py b/pydantic/functional_validators.py
--- a/pydantic/functional_validators.py
+++ b/pydantic/functional_validators.py
@@ -417,6 +417,21 @@ def __call__(  # noqa: D102
         ...
 
 
+class FreeModelBeforeValidatorWithoutInfo(Protocol):
+    """A @model_validator decorated function signature.
+    This is used when `mode='before'` and the function does not have info argument.
+    """
+
+    def __call__(  # noqa: D102
+        self,
+        # this can be a dict, a model instance
+        # or anything else that gets passed to validate_python
+        # thus validators _must_ handle all cases
+        __value: Any,
+    ) -> Any:
+        ...
+
+
 class ModelBeforeValidatorWithoutInfo(Protocol):
     """A @model_validator decorated function signature.
     This is used when `mode='before'` and the function does not have info argument.
@@ -433,6 +448,20 @@ def __call__(  # noqa: D102
         ...
 
 
+class FreeModelBeforeValidator(Protocol):
+    """A `@model_validator` decorated function signature. This is used when `mode='before'`."""
+
+    def __call__(  # noqa: D102
+        self,
+        # this can be a dict, a model instance
+        # or anything else that gets passed to validate_python
+        # thus validators _must_ handle all cases
+        __value: Any,
+        __info: _core_schema.ValidationInfo,
+    ) -> Any:
+        ...
+
+
 class ModelBeforeValidator(Protocol):
     """A `@model_validator` decorated function signature. This is used when `mode='before'`."""
 
@@ -457,7 +486,9 @@ def __call__(  # noqa: D102
 """A `@model_validator` decorated function signature. This is used when `mode='after'`."""
 
 _AnyModelWrapValidator = Union[ModelWrapValidator[_ModelType], ModelWrapValidatorWithoutInfo[_ModelType]]
-_AnyModeBeforeValidator = Union[ModelBeforeValidator, ModelBeforeValidatorWithoutInfo]
+_AnyModeBeforeValidator = Union[
+    FreeModelBeforeValidator, ModelBeforeValidator, FreeModelBeforeValidatorWithoutInfo, ModelBeforeValidatorWithoutInfo
+]
 _AnyModelAfterValidator = Union[ModelAfterValidator[_ModelType], ModelAfterValidatorWithoutInfo[_ModelType]]
 
 
@@ -499,8 +530,6 @@ def model_validator(
 
     Example usage:
     ```py
-    from typing import Optional
-
     from typing_extensions import Self
 
     from pydantic import BaseModel, ValidationError, model_validator
@@ -525,8 +554,7 @@ def verify_square(self) -> Self:
         print(e)
         '''
         1 validation error for Square
-        __root__
-          width and height do not match (type=value_error)
+          Value error, width and height do not match [type=value_error, input_value={'width': 1, 'height': 2}, input_type=dict]
         '''
     ```
 


Test patch:
diff --git a/tests/mypy/modules/success.py b/tests/mypy/modules/success.py
--- a/tests/mypy/modules/success.py
+++ b/tests/mypy/modules/success.py
@@ -42,6 +42,7 @@
     WrapValidator,
     create_model,
     field_validator,
+    model_validator,
     root_validator,
     validate_call,
 )
@@ -308,3 +309,15 @@ class Abstract(BaseModel):
 
 class Concrete(Abstract):
     class_id = 1
+
+
+def two_dim_shape_validator(v: Dict[str, Any]) -> Dict[str, Any]:
+    assert 'volume' not in v, 'shape is 2d, cannot have volume'
+    return v
+
+
+class Square(BaseModel):
+    width: float
+    height: float
+
+    free_validator = model_validator(mode='before')(two_dim_shape_validator)
diff --git a/tests/mypy/outputs/1.0.1/mypy-default_ini/success.py b/tests/mypy/outputs/1.0.1/mypy-default_ini/success.py
--- a/tests/mypy/outputs/1.0.1/mypy-default_ini/success.py
+++ b/tests/mypy/outputs/1.0.1/mypy-default_ini/success.py
@@ -42,6 +42,7 @@
     WrapValidator,
     create_model,
     field_validator,
+    model_validator,
     root_validator,
     validate_call,
 )
@@ -314,3 +315,15 @@ class Abstract(BaseModel):
 
 class Concrete(Abstract):
     class_id = 1
+
+
+def two_dim_shape_validator(v: Dict[str, Any]) -> Dict[str, Any]:
+    assert 'volume' not in v, 'shape is 2d, cannot have volume'
+    return v
+
+
+class Square(BaseModel):
+    width: float
+    height: float
+
+    free_validator = model_validator(mode='before')(two_dim_shape_validator)
diff --git a/tests/mypy/outputs/1.0.1/pyproject-default_toml/success.py b/tests/mypy/outputs/1.0.1/pyproject-default_toml/success.py
--- a/tests/mypy/outputs/1.0.1/pyproject-default_toml/success.py
+++ b/tests/mypy/outputs/1.0.1/pyproject-default_toml/success.py
@@ -42,6 +42,7 @@
     WrapValidator,
     create_model,
     field_validator,
+    model_validator,
     root_validator,
     validate_call,
 )
@@ -314,3 +315,15 @@ class Abstract(BaseModel):
 
 class Concrete(Abstract):
     class_id = 1
+
+
+def two_dim_shape_validator(v: Dict[str, Any]) -> Dict[str, Any]:
+    assert 'volume' not in v, 'shape is 2d, cannot have volume'
+    return v
+
+
+class Square(BaseModel):
+    width: float
+    height: float
+
+    free_validator = model_validator(mode='before')(two_dim_shape_validator)