Accept a /v1 suffix on the vLLM reranker base_url

vllm_base_url moves to utils.py and is shared with the embedder, so the same
endpoint works written either way. Writing /v1 posted to /v1/v1/rerank.
This commit is contained in:
Yiorgis Gozadinos 2026-08-27 17:42:09 +03:00
parent ad100ecd4d
commit 0c67db4459
No known key found for this signature in database
6 changed files with 45 additions and 17 deletions

View file

@ -37,6 +37,7 @@
- Inspector search results mark truncated previews with an ellipsis.
- Document titles, URIs, headings and database names render as text, not Rich
markup, in `search` output, chat citations and the chat document filter.
- `reranking.model.base_url` accepts the endpoint with or without the `/v1` path, matching the `vllm` embedder. Writing `/v1` produced a request to `/v1/v1/rerank`.
- An unrecognized chat model provider raises `Unknown model provider '<name>'` instead of reaching pydantic-ai as a `provider:name` string, and outranks the `api_key` check, so an unusable provider is no longer reported as a missing vendor environment variable.
## [0.78.0] - 2026-08-24

View file

@ -472,10 +472,10 @@ reranking:
model:
provider: vllm
name: Qwen/Qwen3-Reranker-4B
base_url: http://localhost:8001
base_url: http://localhost:8001/v1
```
**Note:** vLLM reranking uses the `/v1/rerank` API endpoint. You need to run a vLLM server separately with a reranking model loaded.
**Note:** vLLM reranking posts to the `/v1/rerank` endpoint. As with the embedder, `base_url` may be written with or without the `/v1` path. You need to run a vLLM server separately with a reranking model loaded.
#### Multimodal reranking
@ -487,7 +487,7 @@ reranking:
model:
provider: vllm
name: nvidia/llama-nemotron-rerank-vl-1b-v2
base_url: http://localhost:8001
base_url: http://localhost:8001/v1
```
Picture chunks are sent as image documents (base64 data URIs) alongside plain text documents in the same rerank request. The flag is supported on the vllm provider only, and the served model must accept multimodal inputs.

View file

@ -8,7 +8,7 @@ from pydantic_ai.providers.ollama import OllamaProvider
from pydantic_ai.providers.openai import OpenAIProvider
from haiku.rag.config import AppConfig, get_config
from haiku.rag.utils import check_api_key_supported
from haiku.rag.utils import check_api_key_supported, vllm_base_url
if TYPE_CHECKING:
from PIL import Image as PILImage
@ -241,7 +241,7 @@ def get_embedder(config: AppConfig | None = None) -> EmbedderWrapper:
if provider == "vllm":
from haiku.rag.embeddings.vllm import VLLMMultimodalEmbedder
base_url = _vllm_base_url(embedding_model.base_url)
base_url = vllm_base_url(embedding_model.base_url)
return VLLMMultimodalEmbedder(
model_name,
vector_dim,
@ -253,13 +253,6 @@ def get_embedder(config: AppConfig | None = None) -> EmbedderWrapper:
raise ValueError(f"Unsupported embedding provider: {provider}")
def _vllm_base_url(base_url: str | None) -> str:
base_url = base_url or "http://localhost:8000/v1"
if not base_url.rstrip("/").endswith("/v1"):
base_url = base_url.rstrip("/") + "/v1"
return base_url
def _get_multimodal_embedder(
embedding_model: "EmbeddingModelConfig",
) -> EmbedderWrapper:
@ -275,7 +268,7 @@ def _get_multimodal_embedder(
if provider == "vllm":
from haiku.rag.embeddings.vllm import VLLMMultimodalEmbedder
base_url = _vllm_base_url(embedding_model.base_url)
base_url = vllm_base_url(embedding_model.base_url)
return VLLMMultimodalEmbedder(
model_name,
vector_dim,

View file

@ -4,6 +4,7 @@ import httpx
from haiku.rag.reranking.base import RerankerBase
from haiku.rag.store.models.chunk import Chunk
from haiku.rag.utils import vllm_base_url
def _document(chunk: Chunk) -> str | dict:
@ -26,7 +27,7 @@ def _document(chunk: Chunk) -> str | dict:
class VLLMReranker(RerankerBase):
def __init__(self, model: str, base_url: str, api_key: str | None = None):
self._model = model
self._base_url = base_url
self._base_url = vllm_base_url(base_url)
self._headers = {
"accept": "application/json",
"Content-Type": "application/json",
@ -47,7 +48,7 @@ class VLLMReranker(RerankerBase):
documents = [_document(chunk) for chunk in chunks]
response = await self._client.post(
f"{self._base_url}/v1/rerank",
f"{self._base_url}/rerank",
json={"model": self._model, "query": query, "documents": documents},
headers=self._headers,
)

View file

@ -62,6 +62,18 @@ def check_api_key_supported(
)
def vllm_base_url(base_url: str | None) -> str:
"""Normalize a vLLM endpoint to its OpenAI-compatible `/v1` root.
Embedders and rerankers take the same endpoint from config, so both accept
it written with or without `/v1`.
"""
base_url = base_url or "http://localhost:8000/v1"
if not base_url.rstrip("/").endswith("/v1"):
base_url = base_url.rstrip("/") + "/v1"
return base_url
def _check_provider_known(provider: str) -> None:
"""Reject a chat provider pydantic-ai cannot resolve.

View file

@ -197,7 +197,7 @@ class TestGetReranker:
{"base_url": "http://localhost:8000"},
{
"_model": "BAAI/bge-reranker-v2-m3",
"_base_url": "http://localhost:8000",
"_base_url": "http://localhost:8000/v1",
},
{},
),
@ -293,11 +293,12 @@ class TestGetReranker:
class _PoolStats:
"""Fake httpx.AsyncClient factory counting constructions and closes."""
"""Fake httpx.AsyncClient factory recording constructions, closes and URLs."""
def __init__(self, response_json):
self.constructed = 0
self.closed = 0
self.urls: list[str] = []
stats = self
class FakeResponse:
@ -312,6 +313,7 @@ class _PoolStats:
stats.constructed += 1
async def post(self, url, json, headers):
stats.urls.append(url)
return FakeResponse()
async def aclose(self):
@ -339,6 +341,25 @@ async def test_vllm_reranker_reuses_pooled_client(monkeypatch):
assert stats.closed == 1
@pytest.mark.asyncio
@pytest.mark.parametrize(
"base_url", ["http://localhost:8000", "http://localhost:8000/v1"]
)
async def test_vllm_reranker_accepts_base_url_with_or_without_v1(monkeypatch, base_url):
"""`reranking.model.base_url` is the same endpoint as the embedder's, which
carries /v1, so both spellings must post to /v1/rerank exactly once."""
from haiku.rag.reranking.vllm import VLLMReranker
stats = _PoolStats({"results": [{"index": 0, "relevance_score": 0.9}]})
monkeypatch.setattr("httpx.AsyncClient", stats.client_class)
reranker = VLLMReranker(model="m", base_url=base_url)
await reranker.rerank("q", [Chunk(content="a", order=0)])
assert stats.urls == ["http://localhost:8000/v1/rerank"]
await reranker.aclose()
@pytest.mark.asyncio
async def test_vllm_reranker_builds_multimodal_documents(monkeypatch):
"""Chunks carrying picture bytes are sent as content-parts documents