diff --git a/CHANGELOG.md b/CHANGELOG.md index 2f5a0752..0eac623e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 ''` 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 diff --git a/docs/configuration/providers.md b/docs/configuration/providers.md index 5111dd33..5b5e5104 100644 --- a/docs/configuration/providers.md +++ b/docs/configuration/providers.md @@ -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. diff --git a/haiku_rag_slim/haiku/rag/embeddings/__init__.py b/haiku_rag_slim/haiku/rag/embeddings/__init__.py index 160328b1..cb55c6b1 100644 --- a/haiku_rag_slim/haiku/rag/embeddings/__init__.py +++ b/haiku_rag_slim/haiku/rag/embeddings/__init__.py @@ -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, diff --git a/haiku_rag_slim/haiku/rag/reranking/vllm.py b/haiku_rag_slim/haiku/rag/reranking/vllm.py index dbeaee83..fac9e0e2 100644 --- a/haiku_rag_slim/haiku/rag/reranking/vllm.py +++ b/haiku_rag_slim/haiku/rag/reranking/vllm.py @@ -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, ) diff --git a/haiku_rag_slim/haiku/rag/utils.py b/haiku_rag_slim/haiku/rag/utils.py index 125da1fc..e3d17780 100644 --- a/haiku_rag_slim/haiku/rag/utils.py +++ b/haiku_rag_slim/haiku/rag/utils.py @@ -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. diff --git a/tests/test_reranker.py b/tests/test_reranker.py index 0400f919..4f98ff44 100644 --- a/tests/test_reranker.py +++ b/tests/test_reranker.py @@ -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