Repository: huggingface/huggingface_hub (python)

Issue:
`huggingface-cli upload` - `_wrapped_lfs_upload` should retry | `http_backoff` retry not working with `_upload_multi_part`/`SliceFileObj`
```
HTTP Error 500 thrown while requesting PUT https://hf-hub-lfs-us-east-1.s3-accelerate.amazonaws.com/repos/...
Traceback (most recent call last):
  File "/usr/local/lib/python3.9/site-packages/huggingface_hub/utils/_http.py", line 405, in hf_raise_for_status
    response.raise_for_status()
  File "/usr/local/lib/python3.9/site-packages/requests/models.py", line 1024, in raise_for_status
    raise HTTPError(http_error_msg, response=self)
requests.exceptions.HTTPError: 400 Client Error: Bad Request for url: https://huggingface.co/api/complete_multipart...
The above exception was the direct cause of the following exception:
Traceback (most recent call last):
File "/usr/local/lib/python3.9/site-packages/huggingface_hub/_commit_api.py", line 431, in _wrapped_lfs_upload
lfs_upload(operation=operation, lfs_batch_action=batch_action, headers=headers, endpoint=endpoint)
File "/usr/local/lib/python3.9/site-packages/huggingface_hub/lfs.py", line 246, in lfs_upload
_upload_multi_part(operation=operation, header=header, chunk_size=chunk_size, upload_url=upload_url)
File "/usr/local/lib/python3.9/site-packages/huggingface_hub/lfs.py", line 355, in _upload_multi_part
hf_raise_for_status(completion_res)
File "/usr/local/lib/python3.9/site-packages/huggingface_hub/utils/_http.py", line 459, in hf_raise_for_status
 raise _format(BadRequestError, message, response) from e
 huggingface_hub.errors.BadRequestError: (Request ID: Root=1-66e43174-2ebfb55456eac7aa1735c83b;cb20fa6b-0659-47af-8f62-2698e507c12b)
Bad request:
   Your proposed upload is smaller than the minimum allowed size
The above exception was the direct cause of the following exception:                                                                                                                                                                                         
File "/usr/local/lib/python3.9/site-packages/huggingface_hub/commands/huggingface_cli.py", line 57, in main
  File "/usr/local/lib/python3.9/site-packages/huggingface_hub/commands/huggingface_cli.py", line 57, in main
    service.run()
  File "/usr/local/lib/python3.9/site-packages/huggingface_hub/commands/upload.py", line 192, in run
    print(self._upload())
  File "/usr/local/lib/python3.9/site-packages/huggingface_hub/commands/upload.py", line 287, in _upload
    return self.api.upload_folder(
  File "/usr/local/lib/python3.9/site-packages/huggingface_hub/utils/_validators.py", line 114, in _inner_fn
    return fn(*args, **kwargs)
  File "/usr/local/lib/python3.9/site-packages/huggingface_hub/hf_api.py", line 1473, in _inner
    return fn(self, *args, **kwargs)
  File "/usr/local/lib/python3.9/site-packages/huggingface_hub/hf_api.py", line 4990, in upload_folder
    commit_info = self.create_commit(
  File "/usr/local/lib/python3.9/site-packages/huggingface_hub/utils/_validators.py", line 114, in _inner_fn
    return fn(*args, **kwargs)
  File "/usr/local/lib/python3.9/site-packages/huggingface_hub/hf_api.py", line 1473, in _inner
    return fn(self, *args, **kwargs)
  File "/usr/local/lib/python3.9/site-packages/huggingface_hub/hf_api.py", line 3901, in create_commit
    self.preupload_lfs_files(
  File "/usr/local/lib/python3.9/site-packages/huggingface_hub/hf_api.py", line 4448, in preupload_lfs_files
    _upload_lfs_files(
  File "/usr/local/lib/python3.9/site-packages/huggingface_hub/utils/_validators.py", line 114, in _inner_fn
    return fn(*args, **kwargs)
  File "/usr/local/lib/python3.9/site-packages/huggingface_hub/_commit_api.py", line 446, in _upload_lfs_files
    thread_map(
  File "/usr/local/lib/python3.9/site-packages/tqdm/contrib/concurrent.py", line 69, in thread_map
    return _executor_map(ThreadPoolExecutor, fn, *iterables, **tqdm_kwargs)
  File "/usr/local/lib/python3.9/site-packages/tqdm/contrib/concurrent.py", line 51, in _executor_map
    return list(tqdm_class(ex.map(fn, *iterables, chunksize=chunksize), **kwargs))
  File "/usr/local/lib/python3.9/site-packages/tqdm/std.py", line 1181, in __iter__
    for obj in iterable:
  File "/usr/lib64/python3.9/concurrent/futures/_base.py", line 609, in result_iterator
    yield fs.pop().result()
  File "/usr/lib64/python3.9/concurrent/futures/_base.py", line 446, in result
    return self.__get_result()
  File "/usr/lib64/python3.9/concurrent/futures/_base.py", line 391, in __get_result
    raise self._exception
  File "/usr/lib64/python3.9/concurrent/futures/thread.py", line 58, in run
    result = self.fn(*self.args, **self.kwargs)
  File "/usr/local/lib/python3.9/site-packages/huggingface_hub/_commit_api.py", line 433, in _wrapped_lfs_upload
    raise RuntimeError(f"Error while uploading '{operation.path_in_repo}' to the Hub.") from exc
RuntimeError: Error while uploading 'part-00537.parquet' to the Hub.
```

Using huggingface_hub@main([3cd3286](https://github.com/huggingface/huggingface_hub/commit/3cd32863bdee76e80117ae2d05eeba68045e75e1))

This issue wastes hours hashing files again. As a side note implementing the caching of `upload-large-folder` path for `upload` would be useful, I find `upload-large-folder` to be unsuitable as it hits API rate limits by creating too many commits, the number of commits in turn affects Dataset Viewer, and in my testing passing `--revision` to `upload-large-folder` gives errors on upload.

With regards to this particular issue I'd suggest retrying in `_wrapped_lfs_upload` rather than raising the exception, which seems to be caused by transient S3 errors, something like:
```
    def _wrapped_lfs_upload(batch_action) -> None:
        try:
            operation = oid2addop[batch_action["oid"]]
            lfs_upload(operation=operation, lfs_batch_action=batch_action, headers=headers, endpoint=endpoint)
        except Exception:
            logger.debug(f"Error while uploading '{operation.path_in_repo}' to the Hub.")
            _wrapped_lfs_upload(batch_action)
```

Pull request description:
http_backoff retry with SliceFileObj
Retry mechanism of [`http_backoff`](https://github.com/huggingface/huggingface_hub/blob/3cd32863bdee76e80117ae2d05eeba68045e75e1/src/huggingface_hub/utils/_http.py#L209-L332) doesn't work from [`lfs_upload`](https://github.com/huggingface/huggingface_hub/blob/3cd32863bdee76e80117ae2d05eeba68045e75e1/src/huggingface_hub/lfs.py#L193-L260)->[`_upload_multi_part`](https://github.com/huggingface/huggingface_hub/blob/3cd32863bdee76e80117ae2d05eeba68045e75e1/src/huggingface_hub/lfs.py#L323C5-L355)->[_upload_parts_iteratively](https://github.com/huggingface/huggingface_hub/blob/3cd32863bdee76e80117ae2d05eeba68045e75e1/src/huggingface_hub/lfs.py#L391-L408) path, `data` is consumed after the first attempt when using `SliceFileObj` because `io_obj_initial_pos` should be [set](https://github.com/huggingface/huggingface_hub/blob/3cd32863bdee76e80117ae2d05eeba68045e75e1/src/huggingface_hub/utils/_http.py#L289-L294) and [reset on retry](https://github.com/huggingface/huggingface_hub/blob/3cd32863bdee76e80117ae2d05eeba68045e75e1/src/huggingface_hub/utils/_http.py#L300-L303), `io_obj_initial_pos` is not set due to `isinstance(kwargs["data"], io.IOBase)`.

This PR changes the check to `isinstance(kwargs["data"], (io.IOBase, SliceFileObj))` and moves `SliceFileObj` to `utils._lfs` to avoid circular import:
```
huggingface_hub> python utils/check_inference_input_params.py
Error importing huggingface_hub.inference._client: cannot import name 'fix_hf_endpoint_in_url' from partially initialized module 'huggingface_hub.utils' (most likely due to a circular import) (huggingface_hub\src\huggingface_hub\utils\__init__.py)
Traceback (most recent call last):
  File "huggingface_hub\utils\check_inference_input_params.py", line 28, in <module>
    from huggingface_hub import InferenceClient
  File "huggingface_hub\src\huggingface_hub\__init__.py", line 530, in __getattr__
    submod = importlib.import_module(submod_path)
             ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "Python311\Lib\importlib\__init__.py", line 126, in import_module
    return _bootstrap._gcd_import(name[level:], package, level)
           ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "huggingface_hub\src\huggingface_hub\inference\_client.py", line 57, in <module>
    from huggingface_hub.inference._common import (
  File "huggingface_hub\src\huggingface_hub\inference\_common.py", line 53, in <module>
    from ..utils import (
  File "huggingface_hub\src\huggingface_hub\utils\__init__.py", line 54, in <module>
    from ._http import (
  File "huggingface_hub\src\huggingface_hub\utils\_http.py", line 33, in <module>
    from huggingface_hub.lfs import SliceFileObj
  File "huggingface_hub\src\huggingface_hub\lfs.py", line 32, in <module>
    from .utils import (
ImportError: cannot import name 'fix_hf_endpoint_in_url' from partially initialized module 'huggingface_hub.utils' (most likely due to a circular import) (huggingface_hub\src\huggingface_hub\utils\__init__.py)
```
Usage of `SliceFileObj` in other areas is updated to import from `utils._lfs`.

Closes #2539 

Patch:
diff --git a/src/huggingface_hub/commands/lfs.py b/src/huggingface_hub/commands/lfs.py
index 4dbf3cf5..e510e345 100644
--- a/src/huggingface_hub/commands/lfs.py
+++ b/src/huggingface_hub/commands/lfs.py
@@ -24,9 +24,10 @@ from argparse import _SubParsersAction
 from typing import Dict, List, Optional
 
 from huggingface_hub.commands import BaseHuggingfaceCLICommand
-from huggingface_hub.lfs import LFS_MULTIPART_UPLOAD_COMMAND, SliceFileObj
+from huggingface_hub.lfs import LFS_MULTIPART_UPLOAD_COMMAND
 
 from ..utils import get_session, hf_raise_for_status, logging
+from ..utils._lfs import SliceFileObj
 
 
 logger = logging.get_logger(__name__)
diff --git a/src/huggingface_hub/hub_mixin.py b/src/huggingface_hub/hub_mixin.py
index 0acb8f04..1e77fffb 100644
--- a/src/huggingface_hub/hub_mixin.py
+++ b/src/huggingface_hub/hub_mixin.py
@@ -829,7 +829,7 @@ class PyTorchModelHubMixin(ModelHubMixin):
 
     @classmethod
     def _load_as_safetensor(cls, model: T, model_file: str, map_location: str, strict: bool) -> T:
-        if packaging.version.parse(safetensors.__version__) < packaging.version.parse("0.4.3"):
+        if packaging.version.parse(safetensors.__version__) < packaging.version.parse("0.4.3"):  # type: ignore [attr-defined]
             load_model_as_safetensor(model, model_file, strict=strict)  # type: ignore [arg-type]
             if map_location != "cpu":
                 logger.warning(
@@ -840,7 +840,7 @@ class PyTorchModelHubMixin(ModelHubMixin):
                 )
                 model.to(map_location)  # type: ignore [attr-defined]
         else:
-            safetensors.torch.load_model(model, model_file, strict=strict, device=map_location)
+            safetensors.torch.load_model(model, model_file, strict=strict, device=map_location)  # type: ignore [arg-type]
         return model
 
 
diff --git a/src/huggingface_hub/inference/_client.py b/src/huggingface_hub/inference/_client.py
index 84131d29..f6421766 100644
--- a/src/huggingface_hub/inference/_client.py
+++ b/src/huggingface_hub/inference/_client.py
@@ -810,26 +810,7 @@ class InferenceClient:
         '{\n\n"activity": "bike ride",\n"animals": ["puppy", "cat", "raccoon"],\n"animals_seen": 3,\n"location": "park"}'
         ```
         """
-        # Determine model
-        # `self.xxx` takes precedence over the method argument only in `chat_completion`
-        # since `chat_completion(..., model=xxx)` is also a payload parameter for the
-        # server, we need to handle it differently
-        model_id_or_url = self.base_url or self.model or model or self.get_recommended_model("text-generation")
-        is_url = model_id_or_url.startswith(("http://", "https://"))
-
-        # First, resolve the model chat completions URL
-        if model_id_or_url == self.base_url:
-            # base_url passed => add server route
-            model_url = model_id_or_url.rstrip("/")
-            if not model_url.endswith("/v1"):
-                model_url += "/v1"
-            model_url += "/chat/completions"
-        elif is_url:
-            # model is a URL => use it directly
-            model_url = model_id_or_url
-        else:
-            # model is a model ID => resolve it + add server route
-            model_url = self._resolve_url(model_id_or_url).rstrip("/") + "/v1/chat/completions"
+        model_url = self._resolve_chat_completion_url(model)
 
         # `model` is sent in the payload. Not used by the server but can be useful for debugging/routing.
         # If it's a ID on the Hub => use it. Otherwise, we use a random string.
@@ -865,6 +846,31 @@ class InferenceClient:
 
         return ChatCompletionOutput.parse_obj_as_instance(data)  # type: ignore[arg-type]
 
+    def _resolve_chat_completion_url(self, model: Optional[str] = None) -> str:
+        # Since `chat_completion(..., model=xxx)` is also a payload parameter for the server, we need to handle 'model' differently.
+        # `self.base_url` and `self.model` takes precedence over 'model' argument only in `chat_completion`.
+        model_id_or_url = self.base_url or self.model or model or self.get_recommended_model("text-generation")
+
+        # Resolve URL if it's a model ID
+        model_url = (
+            model_id_or_url
+            if model_id_or_url.startswith(("http://", "https://"))
+            else self._resolve_url(model_id_or_url, task="text-generation")
+        )
+
+        # Strip trailing /
+        model_url = model_url.rstrip("/")
+
+        # Append /chat/completions if not already present
+        if model_url.endswith("/v1"):
+            model_url += "/chat/completions"
+
+        # Append /v1/chat/completions if not already present
+        if not model_url.endswith("/chat/completions"):
+            model_url += "/v1/chat/completions"
+
+        return model_url
+
     def document_question_answering(
         self,
         image: ContentT,
diff --git a/src/huggingface_hub/inference/_generated/_async_client.py b/src/huggingface_hub/inference/_generated/_async_client.py
index 98d30b49..095cb376 100644
--- a/src/huggingface_hub/inference/_generated/_async_client.py
+++ b/src/huggingface_hub/inference/_generated/_async_client.py
@@ -850,26 +850,7 @@ class AsyncInferenceClient:
         '{\n\n"activity": "bike ride",\n"animals": ["puppy", "cat", "raccoon"],\n"animals_seen": 3,\n"location": "park"}'
         ```
         """
-        # Determine model
-        # `self.xxx` takes precedence over the method argument only in `chat_completion`
-        # since `chat_completion(..., model=xxx)` is also a payload parameter for the
-        # server, we need to handle it differently
-        model_id_or_url = self.base_url or self.model or model or self.get_recommended_model("text-generation")
-        is_url = model_id_or_url.startswith(("http://", "https://"))
-
-        # First, resolve the model chat completions URL
-        if model_id_or_url == self.base_url:
-            # base_url passed => add server route
-            model_url = model_id_or_url.rstrip("/")
-            if not model_url.endswith("/v1"):
-                model_url += "/v1"
-            model_url += "/chat/completions"
-        elif is_url:
-            # model is a URL => use it directly
-            model_url = model_id_or_url
-        else:
-            # model is a model ID => resolve it + add server route
-            model_url = self._resolve_url(model_id_or_url).rstrip("/") + "/v1/chat/completions"
+        model_url = self._resolve_chat_completion_url(model)
 
         # `model` is sent in the payload. Not used by the server but can be useful for debugging/routing.
         # If it's a ID on the Hub => use it. Otherwise, we use a random string.
@@ -905,6 +886,31 @@ class AsyncInferenceClient:
 
         return ChatCompletionOutput.parse_obj_as_instance(data)  # type: ignore[arg-type]
 
+    def _resolve_chat_completion_url(self, model: Optional[str] = None) -> str:
+        # Since `chat_completion(..., model=xxx)` is also a payload parameter for the server, we need to handle 'model' differently.
+        # `self.base_url` and `self.model` takes precedence over 'model' argument only in `chat_completion`.
+        model_id_or_url = self.base_url or self.model or model or self.get_recommended_model("text-generation")
+
+        # Resolve URL if it's a model ID
+        model_url = (
+            model_id_or_url
+            if model_id_or_url.startswith(("http://", "https://"))
+            else self._resolve_url(model_id_or_url, task="text-generation")
+        )
+
+        # Strip trailing /
+        model_url = model_url.rstrip("/")
+
+        # Append /chat/completions if not already present
+        if model_url.endswith("/v1"):
+            model_url += "/chat/completions"
+
+        # Append /v1/chat/completions if not already present
+        if not model_url.endswith("/chat/completions"):
+            model_url += "/v1/chat/completions"
+
+        return model_url
+
     async def document_question_answering(
         self,
         image: ContentT,
diff --git a/src/huggingface_hub/lfs.py b/src/huggingface_hub/lfs.py
index c96ed3d7..2ea85260 100644
--- a/src/huggingface_hub/lfs.py
+++ b/src/huggingface_hub/lfs.py
@@ -16,10 +16,8 @@
 
 import inspect
 import io
-import os
 import re
 import warnings
-from contextlib import AbstractContextManager
 from dataclasses import dataclass
 from math import ceil
 from os.path import getsize
@@ -39,6 +37,7 @@ from .utils import (
     tqdm,
     validate_hf_hub_args,
 )
+from .utils._lfs import SliceFileObj
 from .utils.sha import sha256, sha_fileobj
 
 
@@ -462,93 +461,3 @@ def _upload_parts_hf_transfer(
         if not supports_callback:
             progress.update(total)
         return output
-
-
-class SliceFileObj(AbstractContextManager):
-    """
-    Utility context manager to read a *slice* of a seekable file-like object as a seekable, file-like object.
-
-    This is NOT thread safe
-
-    Inspired by stackoverflow.com/a/29838711/593036
-
-    Credits to @julien-c
-
-    Args:
-        fileobj (`BinaryIO`):
-            A file-like object to slice. MUST implement `tell()` and `seek()` (and `read()` of course).
-            `fileobj` will be reset to its original position when exiting the context manager.
-        seek_from (`int`):
-            The start of the slice (offset from position 0 in bytes).
-        read_limit (`int`):
-            The maximum number of bytes to read from the slice.
-
-    Attributes:
-        previous_position (`int`):
-            The previous position
-
-    Examples:
-
-    Reading 200 bytes with an offset of 128 bytes from a file (ie bytes 128 to 327):
-    ```python
-    >>> with open("path/to/file", "rb") as file:
-    ...     with SliceFileObj(file, seek_from=128, read_limit=200) as fslice:
-    ...         fslice.read(...)
-    ```
-
-    Reading a file in chunks of 512 bytes
-    ```python
-    >>> import os
-    >>> chunk_size = 512
-    >>> file_size = os.getsize("path/to/file")
-    >>> with open("path/to/file", "rb") as file:
-    ...     for chunk_idx in range(ceil(file_size / chunk_size)):
-    ...         with SliceFileObj(file, seek_from=chunk_idx * chunk_size, read_limit=chunk_size) as fslice:
-    ...             chunk = fslice.read(...)
-
-    ```
-    """
-
-    def __init__(self, fileobj: BinaryIO, seek_from: int, read_limit: int):
-        self.fileobj = fileobj
-        self.seek_from = seek_from
-        self.read_limit = read_limit
-
-    def __enter__(self):
-        self._previous_position = self.fileobj.tell()
-        end_of_stream = self.fileobj.seek(0, os.SEEK_END)
-        self._len = min(self.read_limit, end_of_stream - self.seek_from)
-        # ^^ The actual number of bytes that can be read from the slice
-        self.fileobj.seek(self.seek_from, io.SEEK_SET)
-        return self
-
-    def __exit__(self, exc_type, exc_value, traceback):
-        self.fileobj.seek(self._previous_position, io.SEEK_SET)
-
-    def read(self, n: int = -1):
-        pos = self.tell()
-        if pos >= self._len:
-            return b""
-        remaining_amount = self._len - pos
-        data = self.fileobj.read(remaining_amount if n < 0 else min(n, remaining_amount))
-        return data
-
-    def tell(self) -> int:
-        return self.fileobj.tell() - self.seek_from
-
-    def seek(self, offset: int, whence: int = os.SEEK_SET) -> int:
-        start = self.seek_from
-        end = start + self._len
-        if whence in (os.SEEK_SET, os.SEEK_END):
-            offset = start + offset if whence == os.SEEK_SET else end + offset
-            offset = max(start, min(offset, end))
-            whence = os.SEEK_SET
-        elif whence == os.SEEK_CUR:
-            cur_pos = self.fileobj.tell()
-            offset = max(start - cur_pos, min(offset, end - cur_pos))
-        else:
-            raise ValueError(f"whence value {whence} is not supported")
-        return self.fileobj.seek(offset, whence) - self.seek_from
-
-    def __iter__(self):
-        yield self.read(n=4 * 1024 * 1024)
diff --git a/src/huggingface_hub/utils/_http.py b/src/huggingface_hub/utils/_http.py
index 84f6302b..076ae557 100644
--- a/src/huggingface_hub/utils/_http.py
+++ b/src/huggingface_hub/utils/_http.py
@@ -43,6 +43,7 @@ from ..errors import (
 )
 from . import logging
 from ._fixes import JSONDecodeError
+from ._lfs import SliceFileObj
 from ._typing import HTTP_METHOD_T
 
 
@@ -290,7 +291,7 @@ def http_backoff(
     # first HTTP request. We need to save the initial position so that the full content
     # of the file is re-sent on http backoff. See warning tip in docstring.
     io_obj_initial_pos = None
-    if "data" in kwargs and isinstance(kwargs["data"], io.IOBase):
+    if "data" in kwargs and isinstance(kwargs["data"], (io.IOBase, SliceFileObj)):
         io_obj_initial_pos = kwargs["data"].tell()
 
     session = get_session()
@@ -473,7 +474,7 @@ def hf_raise_for_status(response: Response, endpoint_name: Optional[str] = None)
 
         # Convert `HTTPError` into a `HfHubHTTPError` to display request information
         # as well (request id and/or server error message)
-        raise _format(HfHubHTTPError, "", response) from e
+        raise _format(HfHubHTTPError, str(e), response) from e
 
 
 def _format(error_type: Type[HfHubHTTPError], custom_message: str, response: Response) -> HfHubHTTPError:
diff --git a/src/huggingface_hub/utils/_lfs.py b/src/huggingface_hub/utils/_lfs.py
new file mode 100644
index 00000000..307f371f
--- /dev/null
+++ b/src/huggingface_hub/utils/_lfs.py
@@ -0,0 +1,110 @@
+# coding=utf-8
+# Copyright 2019-present, the HuggingFace Inc. team.
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+#     http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+"""Git LFS related utilities"""
+
+import io
+import os
+from contextlib import AbstractContextManager
+from typing import BinaryIO
+
+
+class SliceFileObj(AbstractContextManager):
+    """
+    Utility context manager to read a *slice* of a seekable file-like object as a seekable, file-like object.
+
+    This is NOT thread safe
+
+    Inspired by stackoverflow.com/a/29838711/593036
+
+    Credits to @julien-c
+
+    Args:
+        fileobj (`BinaryIO`):
+            A file-like object to slice. MUST implement `tell()` and `seek()` (and `read()` of course).
+            `fileobj` will be reset to its original position when exiting the context manager.
+        seek_from (`int`):
+            The start of the slice (offset from position 0 in bytes).
+        read_limit (`int`):
+            The maximum number of bytes to read from the slice.
+
+    Attributes:
+        previous_position (`int`):
+            The previous position
+
+    Examples:
+
+    Reading 200 bytes with an offset of 128 bytes from a file (ie bytes 128 to 327):
+    ```python
+    >>> with open("path/to/file", "rb") as file:
+    ...     with SliceFileObj(file, seek_from=128, read_limit=200) as fslice:
+    ...         fslice.read(...)
+    ```
+
+    Reading a file in chunks of 512 bytes
+    ```python
+    >>> import os
+    >>> chunk_size = 512
+    >>> file_size = os.getsize("path/to/file")
+    >>> with open("path/to/file", "rb") as file:
+    ...     for chunk_idx in range(ceil(file_size / chunk_size)):
+    ...         with SliceFileObj(file, seek_from=chunk_idx * chunk_size, read_limit=chunk_size) as fslice:
+    ...             chunk = fslice.read(...)
+
+    ```
+    """
+
+    def __init__(self, fileobj: BinaryIO, seek_from: int, read_limit: int):
+        self.fileobj = fileobj
+        self.seek_from = seek_from
+        self.read_limit = read_limit
+
+    def __enter__(self):
+        self._previous_position = self.fileobj.tell()
+        end_of_stream = self.fileobj.seek(0, os.SEEK_END)
+        self._len = min(self.read_limit, end_of_stream - self.seek_from)
+        # ^^ The actual number of bytes that can be read from the slice
+        self.fileobj.seek(self.seek_from, io.SEEK_SET)
+        return self
+
+    def __exit__(self, exc_type, exc_value, traceback):
+        self.fileobj.seek(self._previous_position, io.SEEK_SET)
+
+    def read(self, n: int = -1):
+        pos = self.tell()
+        if pos >= self._len:
+            return b""
+        remaining_amount = self._len - pos
+        data = self.fileobj.read(remaining_amount if n < 0 else min(n, remaining_amount))
+        return data
+
+    def tell(self) -> int:
+        return self.fileobj.tell() - self.seek_from
+
+    def seek(self, offset: int, whence: int = os.SEEK_SET) -> int:
+        start = self.seek_from
+        end = start + self._len
+        if whence in (os.SEEK_SET, os.SEEK_END):
+            offset = start + offset if whence == os.SEEK_SET else end + offset
+            offset = max(start, min(offset, end))
+            whence = os.SEEK_SET
+        elif whence == os.SEEK_CUR:
+            cur_pos = self.fileobj.tell()
+            offset = max(start - cur_pos, min(offset, end - cur_pos))
+        else:
+            raise ValueError(f"whence value {whence} is not supported")
+        return self.fileobj.seek(offset, whence) - self.seek_from
+
+    def __iter__(self):
+        yield self.read(n=4 * 1024 * 1024)


Test patch:
diff --git a/tests/test_inference_client.py b/tests/test_inference_client.py
index 1c6d3993..001495ad 100644
--- a/tests/test_inference_client.py
+++ b/tests/test_inference_client.py
@@ -16,6 +16,7 @@ import json
 import time
 import unittest
 from pathlib import Path
+from typing import Optional
 from unittest.mock import MagicMock, patch
 
 import numpy as np
@@ -918,27 +919,6 @@ class TestOpenAICompatibility(unittest.TestCase):
             InferenceClient(model="meta-llama/Meta-Llama-3-8B-Instruct", base_url="http://127.0.0.1:8000")
 
 
-@pytest.mark.parametrize(
-    "base_url",
-    [
-        "http://0.0.0.0:8080/v1",  # expected from OpenAI client
-        "http://0.0.0.0:8080",  # but not mandatory
-        "http://0.0.0.0:8080/v1/",  # ok with trailing '/' as well
-        "http://0.0.0.0:8080/",  # ok with trailing '/' as well
-    ],
-)
-def test_chat_completion_base_url_works_with_v1(base_url: str):
-    """Test that `/v1/chat/completions` is correctly appended to the base URL.
-
-    This is a regression test for https://github.com/huggingface/huggingface_hub/issues/2414
-    """
-    with patch("huggingface_hub.inference._client.InferenceClient.post") as post_mock:
-        client = InferenceClient(base_url=base_url)
-        post_mock.return_value = "{}"
-        client.chat_completion(messages=CHAT_COMPLETION_MESSAGES, stream=False)
-    assert post_mock.call_args_list[0].kwargs["model"] == "http://0.0.0.0:8080/v1/chat/completions"
-
-
 @pytest.mark.parametrize("stop_signal", [b"data: [DONE]", b"data: [DONE]\n", b"data: [DONE] "])
 def test_stream_text_generation_response(stop_signal: bytes):
     data = [
@@ -970,3 +950,108 @@ def test_stream_chat_completion_response(stop_signal: bytes):
     assert len(output) == 2
     assert output[0].choices[0].delta.content == "Both"
     assert output[1].choices[0].delta.content == " Rust"
+
+
+INFERENCE_API_URL = "https://api-inference.huggingface.co/models"
+INFERENCE_ENDPOINT_URL = "https://rur2d6yoccusjxgn.us-east-1.aws.endpoints.huggingface.cloud"  # example
+LOCAL_TGI_URL = "http://0.0.0.0:8080"
+
+
+@pytest.mark.parametrize(
+    ("client_model", "client_base_url", "model", "expected_url"),
+    [
+        (
+            # Inference API - model global to client
+            "username/repo_name",
+            None,
+            None,
+            f"{INFERENCE_API_URL}/username/repo_name/v1/chat/completions",
+        ),
+        (
+            # Inference API - model specific to request
+            None,
+            None,
+            "username/repo_name",
+            f"{INFERENCE_API_URL}/username/repo_name/v1/chat/completions",
+        ),
+        (
+            # Inference Endpoint - url global to client as 'model'
+            INFERENCE_ENDPOINT_URL,
+            None,
+            None,
+            f"{INFERENCE_ENDPOINT_URL}/v1/chat/completions",
+        ),
+        (
+            # Inference Endpoint - url global to client as 'base_url'
+            None,
+            INFERENCE_ENDPOINT_URL,
+            None,
+            f"{INFERENCE_ENDPOINT_URL}/v1/chat/completions",
+        ),
+        (
+            # Inference Endpoint - url specific to request
+            None,
+            None,
+            INFERENCE_ENDPOINT_URL,
+            f"{INFERENCE_ENDPOINT_URL}/v1/chat/completions",
+        ),
+        (
+            # Inference Endpoint - url global to client as 'base_url' - explicit model id
+            None,
+            INFERENCE_ENDPOINT_URL,
+            "username/repo_name",
+            f"{INFERENCE_ENDPOINT_URL}/v1/chat/completions",
+        ),
+        (
+            # Inference Endpoint - full url global to client as 'model'
+            f"{INFERENCE_ENDPOINT_URL}/v1/chat/completions",
+            None,
+            None,
+            f"{INFERENCE_ENDPOINT_URL}/v1/chat/completions",
+        ),
+        (
+            # Inference Endpoint - full url global to client as 'base_url'
+            None,
+            f"{INFERENCE_ENDPOINT_URL}/v1/chat/completions",
+            None,
+            f"{INFERENCE_ENDPOINT_URL}/v1/chat/completions",
+        ),
+        (
+            # Inference Endpoint - full url specific to request
+            None,
+            None,
+            f"{INFERENCE_ENDPOINT_URL}/v1/chat/completions",
+            f"{INFERENCE_ENDPOINT_URL}/v1/chat/completions",
+        ),
+        (
+            # Inference Endpoint - url with '/v1' (OpenAI compatibility)
+            # Regression test for https://github.com/huggingface/huggingface_hub/pull/2418
+            None,
+            None,
+            f"{INFERENCE_ENDPOINT_URL}/v1",
+            f"{INFERENCE_ENDPOINT_URL}/v1/chat/completions",
+        ),
+        (
+            # Inference Endpoint - url with '/v1/' (OpenAI compatibility)
+            # Regression test for https://github.com/huggingface/huggingface_hub/pull/2418
+            None,
+            None,
+            f"{INFERENCE_ENDPOINT_URL}/v1/",
+            f"{INFERENCE_ENDPOINT_URL}/v1/chat/completions",
+        ),
+        (
+            # Local TGI with trailing '/v1'
+            # Regression test for https://github.com/huggingface/huggingface_hub/issues/2414
+            f"{LOCAL_TGI_URL}/v1",  # expected from OpenAI client
+            None,
+            None,
+            f"{LOCAL_TGI_URL}/v1/chat/completions",
+        ),
+    ],
+)
+def test_resolve_chat_completion_url(
+    client_model: Optional[str], client_base_url: Optional[str], model: Optional[str], expected_url: str
+):
+    client = InferenceClient(model=client_model, base_url=client_base_url)
+    url = client._resolve_chat_completion_url(model)
+    assert url == expected_url
diff --git a/tests/test_lfs.py b/tests/test_lfs.py
index b0f9c18b..dde70a57 100644
--- a/tests/test_lfs.py
+++ b/tests/test_lfs.py
@@ -3,8 +3,9 @@ import unittest
 from hashlib import sha256
 from io import BytesIO
 
-from huggingface_hub.lfs import SliceFileObj, UploadInfo
+from huggingface_hub.lfs import UploadInfo
 from huggingface_hub.utils import SoftTemporaryDirectory
+from huggingface_hub.utils._lfs import SliceFileObj
 
 
 class TestUploadInfo(unittest.TestCase):