From ad100ecd4d3ad26c25ec697c284235e6bc1c6cbe Mon Sep 17 00:00:00 2001 From: Yiorgis Gozadinos Date: Thu, 27 Aug 2026 11:03:11 +0300 Subject: [PATCH 1/2] Reject unknown chat model providers and rename gemini to google --- CHANGELOG.md | 5 +++ docs/configuration/providers.md | 4 +- haiku_rag_slim/haiku/rag/config/models.py | 2 +- haiku_rag_slim/haiku/rag/utils.py | 29 +++++++++++++- tests/test_utils.py | 47 +++++++++++++++++++---- 5 files changed, 75 insertions(+), 12 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 816cf1f8..2f5a0752 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,10 @@ ## [Unreleased] +### Changed + +- Chat model provider `gemini` renamed to `google`, matching pydantic-ai. Update `provider: gemini` to `provider: google`. + ### Added - `lancedb.databases` configures a named set of local or remote databases. @@ -33,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. +- 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 90d76f26..5111dd33 100644 --- a/docs/configuration/providers.md +++ b/docs/configuration/providers.md @@ -127,7 +127,7 @@ qa: Same mechanism, opposite direction. Without `extra_body` the Gemma-4 chat template defaults to non-thinking and dumps a verbose answer straight into `content`. With it on, vLLM (started with `--reasoning-parser`) populates the parsed `reasoning` field and leaves `content` as the concise final answer. -**Provider support:** honored by openai, ollama, anthropic, and groq via pydantic-ai's `ModelSettings.extra_body`. Silently ignored by gemini and bedrock. +**Provider support:** honored by openai, ollama, anthropic, and groq via pydantic-ai's `ModelSettings.extra_body`. Silently ignored by google and bedrock. ## Embedding Providers @@ -393,7 +393,7 @@ Any provider supported by Pydantic AI can be used. Examples: # Google Gemini qa: model: - provider: gemini + provider: google name: gemini-1.5-flash # Groq diff --git a/haiku_rag_slim/haiku/rag/config/models.py b/haiku_rag_slim/haiku/rag/config/models.py index e019ecd5..a8a938c5 100644 --- a/haiku_rag_slim/haiku/rag/config/models.py +++ b/haiku_rag_slim/haiku/rag/config/models.py @@ -36,7 +36,7 @@ class ModelConfig(ConfigModel): `ModelSettings.extra_body`. Provider-side escape hatch for keys haiku.rag doesn't model explicitly (e.g. vLLM's `chat_template_kwargs.enable_thinking: false` for Qwen3). - Honored by openai/ollama/anthropic/groq; ignored by gemini/bedrock. + Honored by openai/ollama/anthropic/groq; ignored by google/bedrock. """ provider: str = "ollama" diff --git a/haiku_rag_slim/haiku/rag/utils.py b/haiku_rag_slim/haiku/rag/utils.py index 8e115f02..125da1fc 100644 --- a/haiku_rag_slim/haiku/rag/utils.py +++ b/haiku_rag_slim/haiku/rag/utils.py @@ -62,6 +62,32 @@ def check_api_key_supported( ) +def _check_provider_known(provider: str) -> None: + """Reject a chat provider pydantic-ai cannot resolve. + + Providers we do not branch on reach pydantic-ai as a `provider:name` string, + so without this an unusable name fails deep inside pydantic-ai with nothing + naming the config key it came from. Asking pydantic-ai's own resolver rather + than keeping a list here leaves a newly added provider working with no + release of ours. + """ + from pydantic_ai.providers import infer_provider_class + + try: + infer_provider_class(provider) + except ImportError: + # pydantic-ai knows the name, its SDK is just not installed here. That + # failure names the extra to install, so leave it to be raised in place. + return + except ValueError: + raise ValueError( + f"Unknown model provider '{provider}'. See " + "https://ai.pydantic.dev/models/ for the providers pydantic-ai " + "supports. An OpenAI-compatible server (vLLM, sglang, LM Studio) " + "uses provider 'openai' with base_url." + ) from None + + def cosine_similarity(vec1: list[float], vec2: list[float]) -> float: """Compute cosine similarity between two vectors.""" dot_product = sum(a * b for a, b in zip(vec1, vec2)) @@ -169,6 +195,7 @@ def get_model( provider = model_config.provider model = model_config.name + _check_provider_known(provider) check_api_key_supported(model_config, {"openai", "ollama"}) if provider == "ollama": @@ -261,7 +288,7 @@ def get_model( return AnthropicModel(model_name=model, settings=anthropic_settings) - elif provider == "gemini": + elif provider == "google": from pydantic_ai.models.google import GoogleModel return GoogleModel( diff --git a/tests/test_utils.py b/tests/test_utils.py index 0a48950f..92224dd3 100644 --- a/tests/test_utils.py +++ b/tests/test_utils.py @@ -390,23 +390,23 @@ def test_get_model_anthropic_thinking_off_disables_adaptive_models(): @pytest.mark.skipif(not HAS_GOOGLE, reason="Google not installed") -def test_get_model_gemini(): - """Test get_model returns GoogleModel for Gemini.""" +def test_get_model_google(): + """Test get_model returns GoogleModel for Google.""" from pydantic_ai.models.google import GoogleModel - model_config = ModelConfig(provider="gemini", name="gemini-2.0-flash-exp") + model_config = ModelConfig(provider="google", name="gemini-2.0-flash-exp") result = get_model(model_config) assert isinstance(result, GoogleModel) @pytest.mark.skipif(not HAS_GOOGLE, reason="Google not installed") @pytest.mark.parametrize("enable_thinking", [True, False]) -def test_get_model_gemini_with_thinking(enable_thinking): - """Test get_model configures thinking for Gemini.""" +def test_get_model_google_with_thinking(enable_thinking): + """Test get_model configures thinking for Google.""" from pydantic_ai.models.google import GoogleModel model_config = ModelConfig( - provider="gemini", + provider="google", name="gemini-2.0-flash-thinking-exp", enable_thinking=enable_thinking, ) @@ -536,14 +536,45 @@ def test_get_model_bedrock_rejects_mantle_only_model(): get_model(model_config) -def test_get_model_unknown_provider(): - """Test get_model returns string format for unknown providers.""" +def test_get_model_passthrough_for_unbranched_provider(): + """A provider pydantic-ai knows but we do not branch on passes through as a + string, so a new pydantic-ai provider needs no haiku.rag release.""" model_config = ModelConfig(provider="mistral", name="mistral-large-latest") result = get_model(model_config) assert isinstance(result, str) assert result == "mistral:mistral-large-latest" +def test_get_model_accepts_provider_whose_sdk_is_missing(monkeypatch): + """A missing vendor SDK is not an unknown provider: that ImportError names + the extra to install, so it must reach the caller unchanged. + + Uses a provider whose SDK *is* installed, so the patch is what produces the + ImportError rather than the environment. + """ + import pydantic_ai.providers + + def _missing_sdk(provider: str): + raise ImportError("Please install the `cohere` package") + + monkeypatch.setattr(pydantic_ai.providers, "infer_provider_class", _missing_sdk) + result = get_model(ModelConfig(provider="cohere", name="command-r")) + + assert result == "cohere:command-r" + + +@pytest.mark.parametrize("provider", ["nonsense", "vllm", "gemini"]) +def test_get_model_rejects_unknown_provider(provider): + """An unknown provider is named here rather than passed through to fail + inside pydantic-ai, where nothing identifies the config it came from. + + `vllm` and `gemini` get no special case: both were haiku.rag's own + vocabulary, and both fail the same way as a typo. + """ + with pytest.raises(ValueError, match=provider): + get_model(ModelConfig(provider=provider, name="whatever")) + + def test_get_package_versions(): """Test get_package_versions returns expected keys.""" from haiku.rag.utils import get_package_versions From 0c67db445939a40e1be107d8c230be228bfd1936 Mon Sep 17 00:00:00 2001 From: Yiorgis Gozadinos Date: Thu, 27 Aug 2026 17:42:09 +0300 Subject: [PATCH 2/2] 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. --- CHANGELOG.md | 1 + docs/configuration/providers.md | 6 ++--- .../haiku/rag/embeddings/__init__.py | 13 +++------- haiku_rag_slim/haiku/rag/reranking/vllm.py | 5 ++-- haiku_rag_slim/haiku/rag/utils.py | 12 +++++++++ tests/test_reranker.py | 25 +++++++++++++++++-- 6 files changed, 45 insertions(+), 17 deletions(-) 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