Merge pull request #584 from ggozad/fix/reject-unknown-chat-providers

Reject unknown chat model providers and rename gemini to google
This commit is contained in:
Yiorgis Gozadinos 2026-08-28 15:55:09 +03:00 committed by GitHub
commit eb50c9c587
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
8 changed files with 120 additions and 29 deletions

View file

@ -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,8 @@
- 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

@ -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
@ -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

@ -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"

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,44 @@ 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.
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 +207,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 +300,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(

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

View file

@ -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