Repository: huggingface/huggingface_hub (python)

Issue:
AsyncInferenceClient model option for OpenAI's client does not work
### Describe the bug

In the AsyncInferenceClient it is written that if you want to use a custom openai base_url you have to use model instead of base_url

Extract from doc "Note: for better compatibility with OpenAI's client, model has been aliased as base_url. Those 2 arguments are mutually exclusive. If using base_url for chat completion, the /chat/completions suffix path will be appended to the base URL. When passing a URL as model, the client will not append any suffix path to it."

But when setting AsyncInferenceClient with model it still add /v1/chat/completion at the end of the endpoint when calling client. The error is still there even when trying to custom the  AsyncInferenceClient. The error seem to be in :
src/huggingface_hub/inference/_providers/hf_inference.py

def _build_chat_completion_url(model_url: str) -> str:
    # 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



### Reproduction

from huggingface_hub import Agent

async def runAgent(data):

    # On récupère l'historique complet si fourni
    messages = data.get("messages")
    prompt = (data.get("prompt") or "").strip()
    
    async with Agent(
        model=data.get("model"),
        api_key=data.get("api_key"),
        servers=data.get("servers"),
    ) as agent:
        
        agent.client = AsyncInferenceClient(
            model= "https://url/deployments/gpt-4o/chat/completions?api-version=2024-06-01",
            api_key= str(AZURE_OPENAI_API_KEY)
        )
        await agent.load_tools()
        #print(agent.available_tools)
        response_acum = ""
        result = []

        try:
            async for chunk in agent.run(prompt):
                if hasattr(chunk, "choices"):
                    delta = chunk.choices[0].delta
                    if delta.content:
                        result.append(delta.content)
                        response_acum += delta.content
                    if delta.tool_calls:
                        for call in delta.tool_calls:
                            if call.function.name:
                                print(f"Calling Tool : {call.function.name} {call.function.arguments}\n")
        except Exception as e:
            tb_str = traceback.format_exc()
            print(f"\nError during agent run: {e}\n{tb_str}", flush=True)
        return result

if __name__ == "__main__":
    data = {
        "messages": [],
        "prompt": "Quel est la liste de tous les repos ?",
        "model": "https://url/deployments/gpt-4o/chat/completions?api-version=2024-06-01",
        "api_key": str(AZURE_OPENAI_API_KEY),
        "servers": [{"type": "http", "config": {"url": "http://localhost:9000/mcp"}}]
    }

    # Use asyncio.run for running the coroutine
    result = asyncio.run(runAgent(data))

### Logs

```shell
rror during agent run: 401, message='Access Denied', url='https://url/deployments/gpt-4o/chat/completions?api-version=2024-06-01/v1/chat/completions'
Traceback (most recent call last):
  File "/home/agents/tiny_agent_ju.py", line 89, in runAgent
    async for chunk in agent.run(prompt):
  File "/home/myenv/lib/python3.12/site-packages/huggingface_hub/inference/_mcp/agent.py", line 83, in run
    async for item in self.process_single_turn_with_tools(
  File "/home/myenv/lib/python3.12/site-packages/huggingface_hub/inference/_mcp/mcp_client.py", line 261, in process_single_turn_with_tools
    response = await self.client.chat.completions.create(
               ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "/home/myenv/lib/python3.12/site-packages/huggingface_hub/inference/_generated/_async_client.py", line 964, in chat_completion
    data = await self._inner_post(request_parameters, stream=stream)
           ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "/home/myenv/lib/python3.12/site-packages/huggingface_hub/inference/_generated/_async_client.py", line 290, in _inner_post
    raise error
  File "/home/myenv/lib/python3.12/site-packages/huggingface_hub/inference/_generated/_async_client.py", line 276, in _inner_post
    response.raise_for_status()
  File "/home/myenv/lib/python3.12/site-packages/aiohttp/client_reqrep.py", line 629, in raise_for_status
    raise ClientResponseError(
aiohttp.client_exceptions.ClientResponseError: 401, message='Access Denied', url='https://url/deployments/gpt-4o/chat/completions?api-version=2024-06-01/v1/chat/completions'
```

### System info

```shell
- huggingface-hub[mcp]: 0.33.1
- Distribution : Ubuntu-22.04 
- Python 3.12.3
```

Pull request description:
[Inference] Correctly build chat completion URL with query parameters
Fixes #3193
This PR fixes a bug in the `_build_chat_completion_url` helper function, which was incorrectly handling URLs that contained query parameters. previously, the function relied on simple string checks like `model_url.endswith("/v1")` or `model_url.endswith("/chat/completions")` to decide whether to append a path suffix. this logic failed when a URL contained a query string, as the suffix would no longer be at the very end of the string.
this led to malformed urls. for e.g.:
```python
input_url = "https://something.com/something/chat/completions?query=123"
output = _build_chat_completion_url(input_url)
# incorrect output: https://something.com/something/chat/completions?query=123/v1/chat/completions
```
the function has been refactored to use `urllib.parse`.

the `model` docstring in `InferenceClient`/`AsyncInferenceClient`  was also misleading, stating that when passing a URL, no suffix is appended, which is incorrect: the client ensures the URL path ends with `/v1/chat/completions`, regardless of whether the URL is passed via the `model` or `base_url` parameter. this has been updated as well in this PR.


Patch:
diff --git a/src/huggingface_hub/inference/_client.py b/src/huggingface_hub/inference/_client.py
index 78032094..25561202 100644
--- a/src/huggingface_hub/inference/_client.py
+++ b/src/huggingface_hub/inference/_client.py
@@ -130,9 +130,7 @@ class InferenceClient:
             or a URL to a deployed Inference Endpoint. Defaults to None, in which case a recommended model is
             automatically selected for the task.
             Note: for better compatibility with OpenAI's client, `model` has been aliased as `base_url`. Those 2
-            arguments are mutually exclusive. If using `base_url` for chat completion, the `/chat/completions` suffix
-            path will be appended to the base URL (see the [TGI Messages API](https://huggingface.co/docs/text-generation-inference/en/messages_api)
-            documentation for details). When passing a URL as `model`, the client will not append any suffix path to it.
+            arguments are mutually exclusive. If a URL is passed as `model` or `base_url` for chat completion, the `(/v1)/chat/completions` suffix path will be appended to the URL.
         provider (`str`, *optional*):
             Name of the provider to use for inference. Can be `"black-forest-labs"`, `"cerebras"`, `"cohere"`, `"fal-ai"`, `"featherless-ai"`, `"fireworks-ai"`, `"groq"`, `"hf-inference"`, `"hyperbolic"`, `"nebius"`, `"novita"`, `"nscale"`, `"openai"`, `"replicate"`, "sambanova"` or `"together"`.
             Defaults to "auto" i.e. the first of the providers available for the model, sorted by the user's order in https://hf.co/settings/inference-providers.
diff --git a/src/huggingface_hub/inference/_generated/_async_client.py b/src/huggingface_hub/inference/_generated/_async_client.py
index 7e75b4e3..4f8fd58a 100644
--- a/src/huggingface_hub/inference/_generated/_async_client.py
+++ b/src/huggingface_hub/inference/_generated/_async_client.py
@@ -118,9 +118,7 @@ class AsyncInferenceClient:
             or a URL to a deployed Inference Endpoint. Defaults to None, in which case a recommended model is
             automatically selected for the task.
             Note: for better compatibility with OpenAI's client, `model` has been aliased as `base_url`. Those 2
-            arguments are mutually exclusive. If using `base_url` for chat completion, the `/chat/completions` suffix
-            path will be appended to the base URL (see the [TGI Messages API](https://huggingface.co/docs/text-generation-inference/en/messages_api)
-            documentation for details). When passing a URL as `model`, the client will not append any suffix path to it.
+            arguments are mutually exclusive. If a URL is passed as `model` or `base_url` for chat completion, the `(/v1)/chat/completions` suffix path will be appended to the URL.
         provider (`str`, *optional*):
             Name of the provider to use for inference. Can be `"black-forest-labs"`, `"cerebras"`, `"cohere"`, `"fal-ai"`, `"featherless-ai"`, `"fireworks-ai"`, `"groq"`, `"hf-inference"`, `"hyperbolic"`, `"nebius"`, `"novita"`, `"nscale"`, `"openai"`, `"replicate"`, "sambanova"` or `"together"`.
             Defaults to "auto" i.e. the first of the providers available for the model, sorted by the user's order in https://hf.co/settings/inference-providers.
diff --git a/src/huggingface_hub/inference/_providers/hf_inference.py b/src/huggingface_hub/inference/_providers/hf_inference.py
index 9bc89e91..a5659eea 100644
--- a/src/huggingface_hub/inference/_providers/hf_inference.py
+++ b/src/huggingface_hub/inference/_providers/hf_inference.py
@@ -2,6 +2,7 @@ import json
 from functools import lru_cache
 from pathlib import Path
 from typing import Any, Dict, Optional, Union
+from urllib.parse import urlparse, urlunparse
 
 from huggingface_hub import constants
 from huggingface_hub.hf_api import InferenceProviderMapping
@@ -125,18 +126,25 @@ class HFInferenceConversational(HFInferenceTask):
 
 
 def _build_chat_completion_url(model_url: str) -> str:
-    # Strip trailing /
-    model_url = model_url.rstrip("/")
+    parsed = urlparse(model_url)
+    path = parsed.path.rstrip("/")
 
-    # Append /chat/completions if not already present
-    if model_url.endswith("/v1"):
-        model_url += "/chat/completions"
+    # If the path already ends with /chat/completions, we're done!
+    if path.endswith("/chat/completions"):
+        return model_url
 
+    # Append /chat/completions if not already present
+    if path.endswith("/v1"):
+        new_path = path + "/chat/completions"
+    # If path was empty or just "/", set the full path
+    elif not path:
+        new_path = "/v1/chat/completions"
     # Append /v1/chat/completions if not already present
-    if not model_url.endswith("/chat/completions"):
-        model_url += "/v1/chat/completions"
+    else:
+        new_path = path + "/v1/chat/completions"
 
-    return model_url
+    # Reconstruct the URL with the new path and original query parameters.
+    return urlunparse(parsed._replace(path=new_path))
 
 
 @lru_cache(maxsize=1)


Test patch:
diff --git a/tests/test_inference_client.py b/tests/test_inference_client.py
index 99c78a61..6aae89d3 100644
--- a/tests/test_inference_client.py
+++ b/tests/test_inference_client.py
@@ -1034,6 +1034,27 @@ LOCAL_TGI_URL = "http://0.0.0.0:8080"
             f"{LOCAL_TGI_URL}/v1",
             f"{LOCAL_TGI_URL}/v1/chat/completions",
         ),
+        # With query parameters
+        (
+            f"{INFERENCE_ENDPOINT_URL}/v1/chat/completions?api-version=1",
+            f"{INFERENCE_ENDPOINT_URL}/v1/chat/completions?api-version=1",
+        ),
+        (
+            f"{INFERENCE_ENDPOINT_URL}/chat/completions?api-version=1",
+            f"{INFERENCE_ENDPOINT_URL}/chat/completions?api-version=1",
+        ),
+        (
+            f"{INFERENCE_ENDPOINT_URL}?api-version=1",
+            f"{INFERENCE_ENDPOINT_URL}/v1/chat/completions?api-version=1",
+        ),
+        (
+            f"{INFERENCE_ENDPOINT_URL}/v1?api-version=1",
+            f"{INFERENCE_ENDPOINT_URL}/v1/chat/completions?api-version=1",
+        ),
+        (
+            f"{INFERENCE_ENDPOINT_URL}/?api-version=1",
+            f"{INFERENCE_ENDPOINT_URL}/v1/chat/completions?api-version=1",
+        ),
     ],
 )
 def test_resolve_chat_completion_url(model_url: str, expected_url: str):